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:
Brandon Thomas 2026-10-03 10:28:56 -04:00
parent dc132bc219
commit cb516c91c3
6 changed files with 326 additions and 25 deletions

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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());

View File

@ -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 }

View File

@ -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());

View File

@ -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);
}