M13.11: scriptui concave fills, real images, wasm cfg gating
onDraw fillPath tessellates any path (non-zero winding trapezoid decomposition: concave, self-intersecting, holes) instead of egui's convex-only polygon. ScriptUIImage holds a file path or embedded PNG/JPEG bytes; drawImage, image controls and icon buttons draw the decoded image. Script-host thread code (host_main, run_threaded, ToHost::Start) is gated off wasm32. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc132bc219
commit
cb516c91c3
|
|
@ -116,6 +116,8 @@ pub struct Widget {
|
|||
/// Frontends paint it instead of the control's default look, unless it contains
|
||||
/// [`DrawOp::Os`] (`drawOSControl()`), which draws the default look first.
|
||||
pub draw: Vec<DrawOp>,
|
||||
/// The image of an image control or icon button.
|
||||
pub image: Option<ImageRef>,
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
|
|
@ -156,8 +158,37 @@ pub enum DrawOp {
|
|||
},
|
||||
/// `drawOSControl()`: the control's default look.
|
||||
Os,
|
||||
/// `drawImage(image, x, y, w?, h?)` (drawn as a placeholder frame).
|
||||
Image { x: f64, y: f64, w: f64, h: f64 },
|
||||
/// `drawImage(image, x, y, w?, h?)`: `w` / `h` 0 = the image's own size.
|
||||
Image {
|
||||
#[serde(default)]
|
||||
image: Option<ImageRef>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A ScriptUIImage (`ScriptUI.newImage`, an image control's or icon button's image, a
|
||||
/// `drawImage` argument): an image file, or embedded PNG / JPEG bytes.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ImageRef {
|
||||
/// File path (or a resource name we can't resolve: drawn as a placeholder).
|
||||
pub src: Option<String>,
|
||||
/// Embedded bytes as a binary string (one character per byte, as in resource strings).
|
||||
pub data: Option<String>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl ImageRef {
|
||||
/// The encoded image bytes (the embedded data, else the file through `services`).
|
||||
pub fn bytes(&self, services: &dyn crate::Services) -> Option<Vec<u8>> {
|
||||
if let Some(d) = &self.data {
|
||||
return d.chars().map(|c| u8::try_from(c as u32).ok()).collect();
|
||||
}
|
||||
services.read_file(self.src.as_deref()?).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl PathSeg {
|
||||
|
|
@ -207,6 +238,67 @@ impl PathSeg {
|
|||
flush(&mut cur, &mut out, false);
|
||||
out
|
||||
}
|
||||
|
||||
/// The area `fillPath` paints, as triangles: every subpath closed, overlapping subpaths
|
||||
/// combined by the non-zero winding rule (`even_odd`: the even-odd rule), so concave,
|
||||
/// self-intersecting and holed paths fill correctly (frontends' polygon fills are often
|
||||
/// convex-only). Exact trapezoid decomposition: the plane is cut into horizontal bands at
|
||||
/// every vertex and edge crossing; inside a band no edges cross, so each inside span is a
|
||||
/// trapezoid (two triangles).
|
||||
pub fn fill_triangles(path: &[PathSeg], n: usize, even_odd: bool) -> Vec<[[f64; 2]; 3]> {
|
||||
// Non-horizontal edges, top to bottom, with their winding direction.
|
||||
let mut edges: Vec<([f64; 2], [f64; 2], i32)> = vec![];
|
||||
for (pts, _) in PathSeg::polylines(path, n) {
|
||||
for i in 0..pts.len() {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
|
||||
if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) || a[1] == b[1] {
|
||||
continue;
|
||||
}
|
||||
edges.push(if a[1] < b[1] { (a, b, 1) } else { (b, a, -1) });
|
||||
}
|
||||
}
|
||||
if edges.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
let x_at = |e: &([f64; 2], [f64; 2], i32), y: f64| e.0[0] + (e.1[0] - e.0[0]) * (y - e.0[1]) / (e.1[1] - e.0[1]);
|
||||
let mut ys: Vec<f64> = edges.iter().flat_map(|e| [e.0[1], e.1[1]]).collect();
|
||||
// Where two edges cross, a band boundary.
|
||||
for i in 0..edges.len() {
|
||||
for j in i + 1..edges.len() {
|
||||
let (a, b) = (&edges[i], &edges[j]);
|
||||
let (lo, hi) = (a.0[1].max(b.0[1]), a.1[1].min(b.1[1]));
|
||||
if hi <= lo {
|
||||
continue;
|
||||
}
|
||||
let (d0, d1) = (x_at(a, lo) - x_at(b, lo), x_at(a, hi) - x_at(b, hi));
|
||||
if d0 * d1 < 0.0 {
|
||||
ys.push(lo + (hi - lo) * d0 / (d0 - d1));
|
||||
}
|
||||
}
|
||||
}
|
||||
ys.sort_by(f64::total_cmp);
|
||||
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
let mut out = vec![];
|
||||
let mut row: Vec<(f64, f64, f64, i32)> = vec![];
|
||||
for w in ys.windows(2) {
|
||||
let (ya, yb) = (w[0], w[1]);
|
||||
let ym = (ya + yb) * 0.5;
|
||||
row.clear();
|
||||
row.extend(edges.iter().filter(|e| e.0[1] < ym && ym < e.1[1]).map(|e| (x_at(e, ym), x_at(e, ya), x_at(e, yb), e.2)));
|
||||
row.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut wind = 0;
|
||||
for k in 0..row.len().saturating_sub(1) {
|
||||
wind += row[k].3;
|
||||
let inside = if even_odd { wind % 2 != 0 } else { wind != 0 };
|
||||
if inside {
|
||||
let (l, r) = (row[k], row[k + 1]);
|
||||
out.push([[l.1, ya], [r.1, ya], [r.2, yb]]);
|
||||
out.push([[l.1, ya], [r.2, yb], [l.2, yb]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget {
|
||||
|
|
@ -689,4 +781,55 @@ mod tests {
|
|||
let j = serde_json::to_value(&ui).unwrap();
|
||||
assert_eq!(j["windows"][0]["root"]["type"], "window");
|
||||
}
|
||||
|
||||
fn area(tris: &[[[f64; 2]; 3]]) -> f64 {
|
||||
tris.iter().map(|t| ((t[1][0] - t[0][0]) * (t[2][1] - t[0][1]) - (t[2][0] - t[0][0]) * (t[1][1] - t[0][1])).abs() * 0.5).sum()
|
||||
}
|
||||
|
||||
fn poly(pts: &[[f64; 2]]) -> Vec<PathSeg> {
|
||||
let mut v = vec![PathSeg::M { x: pts[0][0], y: pts[0][1] }];
|
||||
v.extend(pts[1..].iter().map(|p| PathSeg::L { x: p[0], y: p[1] }));
|
||||
v.push(PathSeg::Z);
|
||||
v
|
||||
}
|
||||
|
||||
fn covers(tris: &[[[f64; 2]; 3]], p: [f64; 2]) -> bool {
|
||||
let s = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
|
||||
tris.iter().any(|t| {
|
||||
let (d0, d1, d2) = (s(t[0], t[1]), s(t[1], t[2]), s(t[2], t[0]));
|
||||
(d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concave_self_intersecting_and_holed_fills() {
|
||||
// An L (concave): area 3, the notch stays empty.
|
||||
let l = poly(&[[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0], [1.0, 2.0], [0.0, 2.0]]);
|
||||
let t = PathSeg::fill_triangles(&l, 48, false);
|
||||
assert!((area(&t) - 3.0).abs() < 1e-9, "{}", area(&t));
|
||||
assert!(covers(&t, [0.5, 1.5]) && !covers(&t, [1.5, 1.5]));
|
||||
// A five-pointed star drawn in one stroke: non-zero fills the centre, even-odd doesn't.
|
||||
let star: Vec<[f64; 2]> = (0..5)
|
||||
.map(|i| {
|
||||
let a = (i * 2 % 5) as f64 * std::f64::consts::TAU / 5.0 - std::f64::consts::FRAC_PI_2;
|
||||
[50.0 + 40.0 * a.cos(), 50.0 + 40.0 * a.sin()]
|
||||
})
|
||||
.collect();
|
||||
let nz = PathSeg::fill_triangles(&poly(&star), 48, false);
|
||||
let eo = PathSeg::fill_triangles(&poly(&star), 48, true);
|
||||
assert!(covers(&nz, [50.0, 50.0]) && !covers(&eo, [50.0, 50.0]));
|
||||
assert!(covers(&eo, [50.0, 15.0]), "a point of the star");
|
||||
assert!(area(&nz) > area(&eo));
|
||||
// A rectangle with a hole (an inner square the other way round).
|
||||
let mut holed = poly(&[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]);
|
||||
holed.extend(poly(&[[3.0, 3.0], [3.0, 7.0], [7.0, 7.0], [7.0, 3.0]]));
|
||||
let h = PathSeg::fill_triangles(&holed, 48, false);
|
||||
assert!((area(&h) - 84.0).abs() < 1e-9 && !covers(&h, [5.0, 5.0]));
|
||||
// rectPath + ellipsePath in one path; an unclosed subpath fills as if closed.
|
||||
let shapes = vec![PathSeg::R { x: 0.0, y: 0.0, w: 4.0, h: 2.0 }, PathSeg::E { x: 10.0, y: 0.0, w: 20.0, h: 20.0 }];
|
||||
let a = area(&PathSeg::fill_triangles(&shapes, 256, false));
|
||||
assert!((a - 8.0 - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}");
|
||||
let open = vec![PathSeg::M { x: 0.0, y: 0.0 }, PathSeg::L { x: 4.0, y: 0.0 }, PathSeg::L { x: 0.0, y: 4.0 }];
|
||||
assert!((area(&PathSeg::fill_triangles(&open, 48, false)) - 8.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -281,9 +281,34 @@ function DropDownList() {}
|
|||
function ListBox() {}
|
||||
function TabbedPanel() {}
|
||||
function Tab() {}
|
||||
function ScriptUIImage() {}
|
||||
// ScriptUIImage: an image file (`src`) or embedded image bytes (`data`, a binary string as in
|
||||
// resource-string images: one character per byte).
|
||||
function ScriptUIImage(src, data, name) {
|
||||
this.__img = true;
|
||||
this.src = src || null;
|
||||
this.data = data || null;
|
||||
this.name = name || (src ? String(src).replace(/^.*[\\/]/, "") : "");
|
||||
this.pathname = src || "";
|
||||
this.format = data ? (data.charCodeAt(0) === 0x89 ? "png" : "jpeg") : String(src || "").replace(/^.*\./, "").toLowerCase();
|
||||
}
|
||||
ScriptUIImage.prototype.toString = function () { return "[object ScriptUIImage]"; };
|
||||
function ListItem() {}
|
||||
|
||||
// Anything ScriptUI accepts as an image: a ScriptUIImage, a File, a path (or resource name), or
|
||||
// a binary string holding PNG / JPEG bytes.
|
||||
function __uiImage(v) {
|
||||
if (v === undefined || v === null || typeof v === "number" || typeof v === "boolean") return null;
|
||||
if (v.__img) return v;
|
||||
if (v.fsName !== undefined) return new ScriptUIImage(String(v.fsName), null, String(v.name || ""));
|
||||
var s = String(v);
|
||||
if ((s.charCodeAt(0) === 0x89 && s.substr(1, 3) === "PNG") || (s.charCodeAt(0) === 0xff && s.charCodeAt(1) === 0xd8)) return new ScriptUIImage(null, s, "");
|
||||
return s ? new ScriptUIImage(s, null, "") : null;
|
||||
}
|
||||
function __uiImageJson(v) {
|
||||
var img = __uiImage(v);
|
||||
return img ? { src: img.src, data: img.data, name: img.name } : null;
|
||||
}
|
||||
|
||||
var ScriptUI = {
|
||||
version: "6.2.2",
|
||||
frameworkName: "EffectCraft",
|
||||
|
|
@ -293,7 +318,7 @@ var ScriptUI = {
|
|||
PenType: { SOLID_COLOR: 0, THEME_COLOR: 1 },
|
||||
Alignment: { LEFT: "left", RIGHT: "right", TOP: "top", BOTTOM: "bottom", CENTER: "center", FILL: "fill" },
|
||||
newFont: function (name, style, size) { return { name: name, style: style, size: size }; },
|
||||
newImage: function () { return {}; },
|
||||
newImage: function (normal) { return __uiImage(normal); },
|
||||
getResourceText: function (t) { return t; },
|
||||
events: { createEvent: function (t) { return { type: t }; } }
|
||||
};
|
||||
|
|
@ -347,7 +372,7 @@ function __uiGraphics() {
|
|||
this.__push({ op: "text", text: String(text), color: pen ? pen.color : null, x: __num(x || 0), y: __num(y || 0), size: __uiFontSize(f, this), style: String(f && f.style !== undefined ? f.style : "") });
|
||||
},
|
||||
drawOSControl: function () { this.__push({ op: "os" }); },
|
||||
drawImage: function (img, x, y, w, h) { this.__push({ op: "image", x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); },
|
||||
drawImage: function (img, x, y, w, h) { this.__push({ op: "image", image: __uiImageJson(img), x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); },
|
||||
drawFocusRing: function () {},
|
||||
measureString: function (s, font, boundaryWidth) {
|
||||
var size = __uiFontSize(font, this);
|
||||
|
|
@ -397,6 +422,11 @@ function __uiInit(o, type, win, parent, bounds, text, props) {
|
|||
o.__wid = __uiNewId();
|
||||
}
|
||||
o.properties = props || {};
|
||||
// Image controls and icon buttons take an image where other controls take their text.
|
||||
if ((type === "image" || type === "iconbutton") && __uiImage(text)) {
|
||||
o.image = __uiImage(text);
|
||||
text = "";
|
||||
}
|
||||
o.text = text === undefined || text === null ? "" : String(text);
|
||||
o.enabled = true;
|
||||
o.visible = true;
|
||||
|
|
@ -459,7 +489,8 @@ function __uiProto() {
|
|||
if (!ctor || t === "window") throw __err("add(): unknown control type " + type);
|
||||
var c = Object.create(__ecGlobal[ctor].prototype);
|
||||
// Value arguments after `text`: slider (value, min, max), progressbar (value, max), lists (items).
|
||||
__uiInit(c, t, this.window, this, bounds, typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text, props);
|
||||
var arg = t === "image" || t === "iconbutton" ? text : typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text;
|
||||
__uiInit(c, t, this.window, this, bounds, arg, props);
|
||||
if ((t === "dropdownlist" || t === "listbox") && text instanceof Array) {
|
||||
for (var i = 0; i < text.length; i++) c.add("item", text[i]);
|
||||
c.text = "";
|
||||
|
|
@ -718,6 +749,7 @@ function __uiJson(c) {
|
|||
}
|
||||
if (c.type === "tabbedpanel") o.activeTab = c.__sel.length ? c.__sel[0] : 0;
|
||||
if (c.__draw && typeof c.onDraw === "function") o.draw = c.__draw;
|
||||
if ((c.type === "image" || c.type === "iconbutton") && c.image) o.image = __uiImageJson(c.image);
|
||||
if (c.type === "edittext") {
|
||||
o.multiline = !!c.__multiline;
|
||||
o.readOnly = !!c.__readonly;
|
||||
|
|
|
|||
|
|
@ -127,10 +127,24 @@ pub(crate) fn native_modal(_: &JsValue, args: &[JsValue], ctx: &mut Context) ->
|
|||
// ---------------------------------------------------------------- script-host threads
|
||||
|
||||
pub(crate) enum ToHost {
|
||||
/// Run the script (script-host threads exist only off the web: the browser runs scripts
|
||||
/// inline).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Start(Session),
|
||||
Event(Session, ScriptUiEvent),
|
||||
}
|
||||
|
||||
impl ToHost {
|
||||
/// The session a message carries (given back when the host has gone).
|
||||
fn into_session(self) -> Session {
|
||||
match self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ToHost::Start(s) => s,
|
||||
ToHost::Event(s, _) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FromHost {
|
||||
session: Session,
|
||||
outcome: crate::Outcome,
|
||||
|
|
@ -191,6 +205,7 @@ fn yield_wait(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool) -
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn host_main(name: String, code: String, link: Link) {
|
||||
let Ok(ToHost::Start(session)) = link.rx.recv() else { return };
|
||||
let mut a = Active::new(session, &name);
|
||||
|
|
@ -248,7 +263,9 @@ fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome {
|
|||
}
|
||||
}
|
||||
|
||||
/// Run a script file on a new script-host thread (see the module docs).
|
||||
/// Run a script file on a new script-host thread (see the module docs). Not on the web, where
|
||||
/// scripts run inline.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::ScriptRequest) -> crate::Outcome {
|
||||
let host = new_host_id();
|
||||
let (to_tx, to_rx) = std::sync::mpsc::channel();
|
||||
|
|
@ -259,8 +276,8 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri
|
|||
if spawned.is_err() {
|
||||
return crate::runtime::run_inline(session, req);
|
||||
}
|
||||
if let Err(std::sync::mpsc::SendError(ToHost::Start(s) | ToHost::Event(s, _))) = to_tx.send(ToHost::Start(std::mem::take(session))) {
|
||||
*session = s;
|
||||
if let Err(std::sync::mpsc::SendError(msg)) = to_tx.send(ToHost::Start(std::mem::take(session))) {
|
||||
*session = msg.into_session();
|
||||
return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() };
|
||||
}
|
||||
receive(session, host, HostEnd { tx: to_tx, rx: from_rx })
|
||||
|
|
@ -302,8 +319,8 @@ pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result<J, Strin
|
|||
session.script_ui.revision += 1;
|
||||
return Err("the script that made this window has ended".into());
|
||||
};
|
||||
if let Err(std::sync::mpsc::SendError(ToHost::Event(s, _) | ToHost::Start(s))) = end.tx.send(ToHost::Event(std::mem::take(session), ev.clone())) {
|
||||
*session = s;
|
||||
if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::Event(std::mem::take(session), ev.clone())) {
|
||||
*session = msg.into_session();
|
||||
session.script_ui.windows.retain(|w| w.host != host);
|
||||
session.script_ui.revision += 1;
|
||||
return Err("the script that made this window has ended".into());
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ log = { workspace = true }
|
|||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
image = { workspace = true }
|
||||
pollster = "0.4"
|
||||
effectcraft-host = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
//! named it). Controls with an `onDraw` handler are painted from their draw list; edit texts and
|
||||
//! sliders send live `changing` updates (onChanging) while typing / dragging.
|
||||
|
||||
use effectcraft_engine::scriptui::{DrawOp, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout};
|
||||
use effectcraft_engine::Services;
|
||||
use effectcraft_engine::scriptui::{DrawOp, ImageRef, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -77,6 +78,7 @@ fn draw_children(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2
|
|||
|
||||
fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, win: u32, w: &Widget, acts: &mut Vec<Act>) {
|
||||
let t = app.tokens;
|
||||
let services = app.session.services.clone();
|
||||
let rect = rect_of(origin, w.bounds);
|
||||
register(app, win, w, rect);
|
||||
let id = egui::Id::new(("sui", win, w.id));
|
||||
|
|
@ -87,20 +89,20 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
|
|||
let os = w.draw.iter().any(|d| matches!(d, DrawOp::Os));
|
||||
if custom && !os {
|
||||
if w.kind.is_container() {
|
||||
paint_draw(ui, rect, &w.draw, &t);
|
||||
paint_draw(ui, rect, &w.draw, &t, &*services);
|
||||
draw_children(app, ui, origin, win, w, acts);
|
||||
return;
|
||||
}
|
||||
let clickable = matches!(w.kind, WidgetKind::Button | WidgetKind::IconButton | WidgetKind::Checkbox | WidgetKind::RadioButton);
|
||||
let r = ui.interact(rect, id, if clickable && w.enabled { Sense::click() } else { Sense::hover() });
|
||||
paint_draw(ui, rect, &w.draw, &t);
|
||||
paint_draw(ui, rect, &w.draw, &t, &*services);
|
||||
if tip(r).clicked() && clickable {
|
||||
acts.push(Act::Click(win, w.id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if custom && w.kind.is_container() {
|
||||
paint_draw(ui, rect, &w.draw, &t);
|
||||
paint_draw(ui, rect, &w.draw, &t, &*services);
|
||||
}
|
||||
match w.kind {
|
||||
WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts),
|
||||
|
|
@ -117,6 +119,9 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
|
|||
}
|
||||
WidgetKind::Button | WidgetKind::IconButton => {
|
||||
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, egui::Button::new(&w.text))).inner;
|
||||
if let Some(img) = &w.image {
|
||||
paint_image(ui, rect.shrink(3.0), img, &*services);
|
||||
}
|
||||
if tip(r).clicked() {
|
||||
acts.push(Act::Click(win, w.id));
|
||||
}
|
||||
|
|
@ -236,16 +241,18 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
|
|||
}
|
||||
}
|
||||
WidgetKind::Image | WidgetKind::Unknown => {
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
if !w.image.as_ref().is_some_and(|img| paint_image(ui, rect, img, &*services)) {
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
if custom && !w.kind.is_container() {
|
||||
paint_draw(ui, rect, &w.draw, &t);
|
||||
paint_draw(ui, rect, &w.draw, &t, &*services);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint an onDraw draw list in `rect` (control coordinates → screen).
|
||||
fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) {
|
||||
fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: &dyn Services) {
|
||||
let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
let col = |c: &Option<[f32; 4]>, theme: Color32| match c {
|
||||
Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(),
|
||||
|
|
@ -255,10 +262,19 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) {
|
|||
for op in ops {
|
||||
match op {
|
||||
DrawOp::Fill { color, path } => {
|
||||
// Any path shape (concave, self-intersecting, with holes): non-zero winding
|
||||
// triangles, not egui's convex-only polygon fill.
|
||||
let c = col(color, t.field_bg);
|
||||
for (pts, _) in PathSeg::polylines(path, 48) {
|
||||
let pts: Vec<egui::Pos2> = pts.iter().map(|q| at(q[0], q[1])).collect();
|
||||
p.add(egui::Shape::convex_polygon(pts, c, Stroke::NONE));
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for tri in PathSeg::fill_triangles(path, 48, false) {
|
||||
let base = mesh.vertices.len() as u32;
|
||||
for q in tri {
|
||||
mesh.colored_vertex(at(q[0], q[1]), c);
|
||||
}
|
||||
mesh.add_triangle(base, base + 1, base + 2);
|
||||
}
|
||||
if !mesh.is_empty() {
|
||||
p.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
}
|
||||
DrawOp::Stroke { color, width, path } => {
|
||||
|
|
@ -271,15 +287,53 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) {
|
|||
DrawOp::Text { text, color, x, y, size, .. } => {
|
||||
p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text));
|
||||
}
|
||||
DrawOp::Image { x, y, w, h } => {
|
||||
let r = Rect::from_min_size(at(*x, *y), vec2(*w as f32, *h as f32));
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
DrawOp::Image { image, x, y, w, h } => {
|
||||
let tex = image.as_ref().and_then(|i| image_texture(ui.ctx(), i, services));
|
||||
let size = match &tex {
|
||||
Some(tx) if *w <= 0.0 || *h <= 0.0 => tx.size_vec2(),
|
||||
_ => vec2(*w as f32, *h as f32),
|
||||
};
|
||||
let r = Rect::from_min_size(at(*x, *y), size);
|
||||
match tex {
|
||||
Some(tx) => {
|
||||
p.image(tx.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
None => {
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
DrawOp::Os => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ScriptUIImage as a texture (decoded once and kept; `None` when it can't be read or
|
||||
/// decoded).
|
||||
fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) -> Option<egui::TextureHandle> {
|
||||
let id = egui::Id::new(("scriptui-image", img));
|
||||
if let Some(cached) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
|
||||
return cached;
|
||||
}
|
||||
let tex = img.bytes(services).and_then(|b| image::load_from_memory(&b).ok()).map(|d| {
|
||||
let rgba = d.to_rgba8();
|
||||
let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
|
||||
ctx.load_texture(format!("scriptui-image-{}", img.name), ci, egui::TextureOptions::LINEAR)
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp(id, tex.clone()));
|
||||
tex
|
||||
}
|
||||
|
||||
/// An image control's or icon button's image, fitted (aspect kept) and centred in `r`.
|
||||
fn paint_image(ui: &egui::Ui, r: Rect, img: &ImageRef, services: &dyn Services) -> bool {
|
||||
let Some(tex) = image_texture(ui.ctx(), img, services) else { return false };
|
||||
let s = tex.size_vec2();
|
||||
let k = (r.width() / s.x.max(1.0)).min(r.height() / s.y.max(1.0)).min(1.0);
|
||||
let dst = Rect::from_center_size(r.center(), s * k);
|
||||
ui.painter().with_clip_rect(r.intersect(ui.clip_rect())).image(tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
true
|
||||
}
|
||||
|
||||
/// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds.
|
||||
fn snug(r: Rect, text: &str) -> Rect {
|
||||
let w = (text.chars().count() as f32 * 7.0 + 26.0).min(r.width());
|
||||
|
|
|
|||
|
|
@ -111,3 +111,58 @@ fn history_panel_jumps_between_branches() {
|
|||
click(&mut h, &format!("history.state.{c}"));
|
||||
assert_eq!(layer_names(&h), ["C", "A"]);
|
||||
}
|
||||
|
||||
/// onDraw fills of concave paths and real images (drawImage, image controls), rendered.
|
||||
#[test]
|
||||
fn on_draw_concave_fills_and_images_render() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-scriptui-img-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = dir.join("green.png");
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 255, 0, 255])).save(&png).unwrap();
|
||||
let png = png.to_string_lossy().replace('\\', "/");
|
||||
let mut s = effectcraft_host::session();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
let code = format!(
|
||||
r#"
|
||||
var w = new Window("palette", "Paint");
|
||||
var c = w.add("group");
|
||||
c.preferredSize = [120, 80];
|
||||
c.onDraw = function () {{
|
||||
var g = this.graphics;
|
||||
g.newPath();
|
||||
// An L: the top-right quarter is a notch (a convex-only fill would cover it).
|
||||
g.moveTo(0, 0); g.lineTo(60, 0); g.lineTo(60, 40); g.lineTo(120, 40); g.lineTo(120, 80); g.lineTo(0, 80); g.closePath();
|
||||
g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, [1, 0, 0, 1]));
|
||||
g.drawImage(ScriptUI.newImage(File("{png}")), 90, 5, 20, 20);
|
||||
}};
|
||||
var im = w.add("image", undefined, File("{png}"));
|
||||
im.preferredSize = [40, 40];
|
||||
w.show();
|
||||
"#
|
||||
);
|
||||
let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "paint.jsx"})).unwrap();
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.run_steps(4);
|
||||
let win = h.state().session.script_ui.windows[0].clone();
|
||||
let g = &win.root.children[0];
|
||||
assert!(matches!(&g.draw[1], effectcraft_engine::scriptui::DrawOp::Image { image: Some(i), .. } if i.src.as_deref() == Some(png.as_str())));
|
||||
assert_eq!(win.root.children[1].image.as_ref().and_then(|i| i.src.clone()).as_deref(), Some(png.as_str()));
|
||||
let img = h.render().expect("render");
|
||||
if let Ok(d) = std::env::var("EC_SNAPSHOT_DIR") {
|
||||
img.save(format!("{d}/scriptui_paint.png")).unwrap();
|
||||
}
|
||||
let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0;
|
||||
let gr = rect(&h, &format!("scriptui.{}.{}", win.id, g.id));
|
||||
let red = |c: [u8; 4]| c[0] > 200 && c[1] < 60 && c[2] < 60;
|
||||
let green = |c: [u8; 4]| c[1] > 200 && c[0] < 60 && c[2] < 60;
|
||||
assert!(red(px(gr.min + vec2(20.0, 20.0))), "inside the L: {:?}", px(gr.min + vec2(20.0, 20.0)));
|
||||
assert!(red(px(gr.min + vec2(100.0, 60.0))), "the L's foot");
|
||||
assert!(!red(px(gr.min + vec2(75.0, 30.0))), "the notch stays empty: {:?}", px(gr.min + vec2(75.0, 30.0)));
|
||||
assert!(green(px(gr.min + vec2(100.0, 15.0))), "drawImage: {:?}", px(gr.min + vec2(100.0, 15.0)));
|
||||
let ir = rect(&h, &format!("scriptui.{}.{}", win.id, win.root.children[1].id));
|
||||
assert!(green(px(ir.center())), "image control: {:?}", px(ir.center()));
|
||||
h.state_mut().session.execute("scriptui.close", json!({})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue