From a202283a607273d3bcd296390b2010f3db54eb7c Mon Sep 17 00:00:00 2001 From: grabowski Date: Sat, 24 Oct 2026 20:46:35 +0700 Subject: [PATCH] Web performance: light animation, gestures on a compositor layer, no forced layouts Measured with the new tools/bench_web.py on the web Studio's house demo, CPU slowed 6x: - Idle: running CSS animations repainted the whole drawing every frame (99% of the main thread in a window). Animations now step on about ten times a second by default ("light", 5% idle); the new toolbar button switches light / smooth / off and the browser remembers it; [build] animation sets the default for built pages; view.animation(). Pan and zoom gestures pause them. Status LEDs pause off-screen and with the rest now. - Pan 28 -> 10 ms, zoom 69 -> 11 ms per step: gestures move the drawing as one compositor layer (transform) and set the viewBox once they end; --iso-k is set on the tags group only and only when it changes; the stage box is read once per frame. - Hover 22 -> 15 ms per move: the tooltip is placed once per frame on its own layer and measured only when its text changes; the Studio's x/y readout updates ten times a second in a contained box; hover work in the Studio runs once per frame. - Long object lists skip layout of rows out of sight (content-visibility). - WASM built with opt-level 3: engine render 11.7 -> 9.4 ms for 10% more download. Checks: Studio 102/102 (animation modes, pan as a layer, contained readout), web 87/87. --- Cargo.toml | 5 +- README.md | 7 + crates/isokit-project/src/assets.rs | 1 + crates/isokit-project/src/lib.rs | 5 + crates/isokit-project/src/page.rs | 1 + docs/ROADMAP.md | 12 +- examples/demo/isokit.toml | 1 + studio/studio.css | 7 + studio/studio.js | 13 +- tools/bench_web.py | 251 ++++++++++++++++++++++++++++ tools/check_studio.py | 39 +++++ tools/check_web.py | 8 + viewer/isokit-view.css | 17 +- viewer/isokit-view.js | 139 ++++++++++++--- viewer/page.html | 1 + 15 files changed, 476 insertions(+), 31 deletions(-) create mode 100644 tools/bench_web.py diff --git a/Cargo.toml b/Cargo.toml index c1ca5a8..cd9bb54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,10 +38,11 @@ wasm-bindgen = "=0.2.129" lto = "thin" codegen-units = 4 -# the web Studio's engine (tools/build_web.py): small over fast +# the web Studio's engine (tools/build_web.py): fast over small (opt-level 3 renders +# about a fifth quicker than "s" for a tenth more download, 0.9 MB gzipped) [profile.web] inherits = "release" -opt-level = "s" +opt-level = 3 lto = true codegen-units = 1 panic = "abort" diff --git a/README.md b/README.md index b50ae50..3500fcb 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,12 @@ the footprint, and `rot` turns the object in 90° steps. Levels: `site` (or any other id) shows buildings closed, while `gf`, `1f`, `2f` … cut them open at that storey. +Animations (fans, smoke, fish, traffic) step on about ten times a second by +default ("light"): running CSS animations repaint the whole drawing every frame, +which keeps a slow laptop busy. The ▷ button in the view toolbar switches +between light, smooth and off, and the browser remembers the choice. In +`isokit.toml`, `[build] animation = "smooth"` sets the default for built pages. + ## Commands | Command | | @@ -241,6 +247,7 @@ ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DI uv run --with playwright python tools/check_studio.py http://127.0.0.1:7890 # GUI + MCP + agent window end to end (Edge) cargo clippy -p isokit-wasm --target wasm32-unknown-unknown -- -D warnings python tools/build_web.py && uv run --with playwright python tools/check_web.py # web Studio end to end +uv run --with playwright python tools/bench_web.py --rate 6 --headed # timings on a CPU slowed 6x (low-power stand-in) ``` The agent window checks run on `tools/mock_llm.py`, a scripted stand-in for an diff --git a/crates/isokit-project/src/assets.rs b/crates/isokit-project/src/assets.rs index 07b7985..8765eea 100644 --- a/crates/isokit-project/src/assets.rs +++ b/crates/isokit-project/src/assets.rs @@ -78,6 +78,7 @@ label = "Animation" [build] out = "dist/index.html" budget = 4000 +# animation = "light" # built pages: "light" (about 10 steps a second), "smooth" or "off" "#; pub const NEW_DOC: &str = r#"{ diff --git a/crates/isokit-project/src/lib.rs b/crates/isokit-project/src/lib.rs index 555616f..13ab8f0 100644 --- a/crates/isokit-project/src/lib.rs +++ b/crates/isokit-project/src/lib.rs @@ -83,6 +83,10 @@ pub struct BuildOpts { /// Node budget per view for `isokit check`. #[serde(default = "budget")] pub budget: usize, + /// How built pages animate until the viewer picks otherwise: "light" + /// (stepped about ten times a second, the default), "smooth" or "off". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub animation: Option, } fn budget() -> usize { @@ -94,6 +98,7 @@ impl Default for BuildOpts { BuildOpts { out: None, budget: budget(), + animation: None, } } } diff --git a/crates/isokit-project/src/page.rs b/crates/isokit-project/src/page.rs index cff44f8..45a9d83 100644 --- a/crates/isokit-project/src/page.rs +++ b/crates/isokit-project/src/page.rs @@ -32,6 +32,7 @@ pub fn static_page(p: &Project) -> Result { }, "views": views, "cfg": p.cfg(None), + "animation": p.manifest.build.animation, }); let css = format!( "{}\n{}", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 552f56e..e336c3c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,7 +71,7 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push. ## Backlog (2026-10) -1. Web performance: the web build runs smoothly on low-power machines +1. ~~Web performance~~ (done, see below) 2. Conveyors: placeable conveyors 3. Producers with adjustable production speed 4. Robotic arms @@ -88,6 +88,15 @@ multi-select (Ctrl+click on the map and in the list, Shift range, Shift+drag box nudge, turn, copy, delete and property edits; a second click on a wall or fence selects the joined run); the Pond with fish swimming round (`swim` primitive, CSS rotation), lily pads, ripples, reeds. +Web performance, measured with `tools/bench_web.py` on the web Studio's house demo with the CPU +slowed 6x (before → after): idle main thread in a browser window 99% → 5% (animations step about ten +times a second instead of repainting the drawing every frame; ▷ toolbar button: light / smooth / off, +`[build] animation`); zoom 69 → 11 ms per wheel step, pan 28 → 10 ms per move (gestures move the +drawing as one compositor layer and draw it again when they end; no forced layouts; `--iso-k` set on +the tags only); hover 22 → 15 ms per move (tooltip placed once per frame on its own layer, x/y +readout ten times a second); rotate 226 → 154 ms, edit 409 → 305 ms until painted (WASM built with +opt-level 3: engine render 11.7 → 9.4 ms at full speed). + ## Phase 2: richer editing - [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open @@ -116,4 +125,5 @@ run); the Pond with fish swimming round (`swim` primitive, CSS rotation), lily p - [ ] `isokit check` rules from the QA checklist: props on paved areas, runs ending in the air, covers drawn over their contents, tag overlaps - [ ] Performance budget test (render ms per view, 60 fps pan) on a 3,500-node scene + (`tools/bench_web.py` measures it by hand for now) - [ ] Packaged desktop app (Tauri); shared projects for the hosted web Studio (behind login) diff --git a/examples/demo/isokit.toml b/examples/demo/isokit.toml index 5109006..7db1313 100644 --- a/examples/demo/isokit.toml +++ b/examples/demo/isokit.toml @@ -36,3 +36,4 @@ label = "Animation" [build] out = "dist/index.html" budget = 4000 +# animation = "light" # built pages: "light" (about 10 steps a second), "smooth" or "off" diff --git a/studio/studio.css b/studio/studio.css index 40334a6..d060eeb 100644 --- a/studio/studio.css +++ b/studio/studio.css @@ -61,6 +61,11 @@ input[type=checkbox] { accent-color: var(--accent); } .st-hint { position: absolute; top: 52px; left: 50%; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--accent); border-radius: 16px; padding: 4px 14px; z-index: 5; pointer-events: none; } .st-status { display: flex; align-items: center; gap: 14px; padding: 4px 10px; background: var(--panel); border-top: 1px solid var(--stroke); color: var(--mute); font-variant-numeric: tabular-nums; flex: 0 0 auto; } .st-status select { padding: 1px 4px; } +/* the pointer readout changes on every move: a fixed box, so the page around it is not laid out again */ +#cursor, #stats { display: inline-block; contain: strict; will-change: transform; height: 1.4em; line-height: 1.4em; white-space: nowrap; overflow: hidden; } +#cursor { width: 15ch; } +#stats { width: 36ch; text-align: right; } +.st-status { contain: layout style; } .st-ver { color: var(--mute); font-size: 11px; text-decoration: none; white-space: nowrap; } .st-ver:hover { color: var(--fg); text-decoration: underline; } .st-link { background: none; border: 0; color: var(--danger); padding: 0; text-decoration: underline; } @@ -162,6 +167,8 @@ input[type=checkbox] { accent-color: var(--accent); } /* the selection box (Shift / Ctrl + drag on the map) */ .st-band { position: fixed; z-index: 30; pointer-events: none; border: 1px dashed var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); } #objects { user-select: none; } +/* long object lists: rows out of sight are not laid out or painted */ +#objects li { content-visibility: auto; contain-intrinsic-size: auto 28px; } /* previews while placing: footprints, wall planes, where a window lands */ .iso-svg .st-ghosts { pointer-events: none; } diff --git a/studio/studio.js b/studio/studio.js index 582d854..698472b 100644 --- a/studio/studio.js +++ b/studio/studio.js @@ -251,7 +251,8 @@ } // ------------------------------------------------------------ view - var drag = null, tool = null; + var drag = null, tool = null, hov = null, hovRaf = 0, hovText = "", hovLate = 0; + function showCursor() { hovLate = 0; var el = $("cursor"); if (el.textContent !== hovText) el.textContent = hovText; } function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); } function mountView() { var src = B ? B.source(toViewerMeta(S.meta)) : IsoKit.httpSource("", toViewerMeta(S.meta)); @@ -264,9 +265,15 @@ var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s"); var ids = selIds(); if (ids.length) S.view.selectObj(ids.length > 1 ? ids : ids[0]); }, + // once per frame at most: pointers send moves faster than that; the x/y readout about ten + // times a second (each new text costs a layout and a paint of the page) onHover: function (w, e) { - if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1); - if (S.placing && !tool && e) hoverPlacing(e); + hov = { w: w, e: e }; + if (!hovRaf) hovRaf = requestAnimationFrame(function () { + var h = hov; hovRaf = 0; + if (h.w) { hovText = "x " + h.w[0].toFixed(1) + " y " + h.w[1].toFixed(1); if (!hovLate) hovLate = setTimeout(showCursor, 100); } + if (S.placing && !tool && h.e) hoverPlacing(h.e); + }); }, onPick: function (i, e) { if (i && i.obj) clickObj(i.obj, e); else select(null); }, onKey: keyHandler, diff --git a/tools/bench_web.py b/tools/bench_web.py new file mode 100644 index 0000000..537eae2 --- /dev/null +++ b/tools/bench_web.py @@ -0,0 +1,251 @@ +"""How the web Studio and a static page run on a slow machine. + +Serves dist/web (build it first: python tools/build_web.py) and opens it and +an example's static page (isokit build examples/house) in headless Edge with +the CPU slowed down (CDP Emulation.setCPUThrottlingRate; 6x is roughly a +low-power laptop next to a desktop). For each page it prints the load time, +DOM size, the main thread's busy share while idle (animations), frames per +main-thread time per input step and long frames (over 50 ms) while panning, +zooming and hovering, and how long a +rotation, a level switch and an edit take until painted. + + uv run --with playwright python tools/bench_web.py [--rate 6] [--headed] [--anim light|smooth|off] + [--json out.json] [--only studio|page] + +Use --headed for the idle figures: headless Edge renders no frames for CSS +animations when nothing else changes, so their cost only shows in a window. +""" + +import argparse +import json +import subprocess +import sys +import time +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parent.parent +DIST = ROOT / "dist" / "web" +PAGE = ROOT / "examples" / "house" / "dist" / "index.html" +PORT = 7941 +ANIM = None + +# in the page: collect long animation frames (over 50 ms, Chromium's LoAF API); +# no requestAnimationFrame loop, which would make headless Chrome render flat out. +# JS_TIMED times an action until the view's SVG has new content and two frames +# have been painted. +JS_SETUP = """() => { + if (window.__bench) return; + window.__bench = { loaf: [] }; + try { + new PerformanceObserver(l => l.getEntries().forEach(e => window.__bench.loaf.push(e.duration))) + .observe({ type: 'long-animation-frame', buffered: false }); + } catch (e) { /* not supported */ } +}""" +JS_TIMED = """async (code) => { + const svg = document.querySelector('.iso-stage .iso-svg'); + const t0 = performance.now(); + const changed = new Promise(r => { const mo = new MutationObserver(() => { mo.disconnect(); r(); }); mo.observe(svg, { childList: true }); }); + (0, eval)(code); + const late = await Promise.race([changed.then(() => false), new Promise(r => setTimeout(() => r(true), 30000))]); + if (late) return -1; + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + return performance.now() - t0; +}""" + + +T0 = time.perf_counter() + + +def log(what): + print(f" [{time.perf_counter() - T0:6.1f}s] {what}", flush=True) + + +def metrics(cdp): + m = {x["name"]: x["value"] for x in cdp.send("Performance.getMetrics")["metrics"]} + return m + + +def busy(cdp, pg, run, steps=1): + """Run `run()` (`steps` input events); answer a dict: wall seconds, the main + thread's busy share, its ms per step, and the long frames (count, longest).""" + pg.evaluate(JS_SETUP) + pg.evaluate("window.__bench.loaf = []") + m0, t0 = metrics(cdp), time.perf_counter() + run() + pg.wait_for_timeout(300) + m1, t1 = metrics(cdp), time.perf_counter() + loaf = pg.evaluate("window.__bench.loaf") + wall, task = t1 - t0, m1["TaskDuration"] - m0["TaskDuration"] + return {"s": wall, "busy": task / wall, "ms_step": 1000 * task / steps, "long": len(loaf), "max_ms": max(loaf or [0])} + + +def gestures(pg, cdp, stage, out, wheel_mod=None): + b = stage.bounding_box() + cx, cy = b["x"] + b["width"] / 2, b["y"] + b["height"] / 2 + out["nodes"] = pg.evaluate("document.querySelectorAll('.iso-stage .iso-svg *').length") + log("idle") + out["idle_busy"] = busy(cdp, pg, lambda: pg.wait_for_timeout(4000))["busy"] + + def pan(): + # the middle button: always the camera, in the Studio and on a page + pg.mouse.move(cx - 150, cy) + pg.mouse.down(button="middle") + for i in range(90): + pg.mouse.move(cx - 150 + 300 * (i % 45) / 45, cy + 60 * ((i // 45) % 2)) + pg.wait_for_timeout(16) + pg.mouse.up(button="middle") + + def zoom(): + pg.mouse.move(cx, cy) + if wheel_mod: + pg.keyboard.down(wheel_mod) + for i in range(24): + pg.mouse.wheel(0, -60 if i < 12 else 60) + pg.wait_for_timeout(30) + if wheel_mod: + pg.keyboard.up(wheel_mod) + + def hover(): + for i in range(90): + pg.mouse.move(b["x"] + b["width"] * (0.2 + 0.6 * i / 90), cy + 40 * ((i // 10) % 2)) + pg.wait_for_timeout(16) + + pg.evaluate("__isokit_view().focus([0, 0, 0], 2.2)") + pg.wait_for_timeout(600) + for name, fn, n in [("pan", pan, 90), ("zoom", zoom, 24), ("hover", hover, 90)]: + log(name) + r = busy(cdp, pg, fn, n) + for k, v in r.items(): + out[name + "_" + k] = v + pg.wait_for_timeout(500) + log("rotate") + out["rotate_ms"] = pg.evaluate(JS_TIMED, "__isokit_view().setRotation((__isokit_view().getRotation() + 1) % 4)") + pg.wait_for_timeout(400) + log("level") + # to another level and back + lv0 = pg.evaluate("__isokit_view().getLevel()") + other = pg.evaluate("[...document.querySelectorAll('.iso-seg button')].map(b => b.dataset.level).find(l => l !== __isokit_view().getLevel())") + out["level_ms"] = pg.evaluate(JS_TIMED, f"__isokit_view().setLevel('{other}')") + pg.wait_for_timeout(400) + pg.evaluate(JS_TIMED, f"__isokit_view().setLevel('{lv0}')") + pg.wait_for_timeout(400) + + +def open_page(b, rate): + pg = b.new_page(viewport={"width": 1366, "height": 768}) + if ANIM: + pg.add_init_script(f"try {{ localStorage.setItem('isokit-anim', '{ANIM}'); }} catch (e) {{}}") + pg.bring_to_front() + cdp = pg.context.new_cdp_session(pg) + cdp.send("Performance.enable") + cdp.send("Emulation.setCPUThrottlingRate", {"rate": rate}) + errs = [] + pg.on("pageerror", lambda e: errs.append(str(e))) + return pg, cdp, errs + + +def bench_studio(b, rate): + out = {} + pg, cdp, errs = open_page(b, rate) + t0 = time.perf_counter() + pg.goto(f"http://127.0.0.1:{PORT}/") + pg.wait_for_selector(".iso-svg [data-obj]", timeout=120000) + out["load_s"] = time.perf_counter() - t0 + pg.add_init_script("window.__isokit_view = () => window.__studio.view") + pg.evaluate("window.__isokit_view = () => window.__studio.view") + pg.locator("#wb-projects").click() + t0 = time.perf_counter() + pg.locator("#wb-nhouse").click() + pg.wait_for_load_state() + pg.wait_for_function("window.__studio && __studio.S.doc && __studio.S.doc.objects.length > 50 && document.querySelector('.iso-svg [data-obj]')", timeout=120000) + out["open_house_s"] = time.perf_counter() - t0 + pg.evaluate("window.__isokit_view = () => window.__studio.view") + pg.wait_for_timeout(1000) + gestures(pg, cdp, pg.locator(".iso-stage"), out) + out["edit_ms"] = pg.evaluate(JS_TIMED, "__studio.op({op: 'add', type: 'tree', x: 14, y: -5})") + pg.wait_for_timeout(300) + out["select_ms"] = pg.evaluate("""() => { const t0 = performance.now(); __studio.select('sofa'); return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(performance.now() - t0)))); }""") + out["errors"] = errs + pg.close() + return out + + +def bench_page(b, rate): + out = {} + pg, cdp, errs = open_page(b, rate) + t0 = time.perf_counter() + pg.goto(PAGE.as_uri()) + pg.wait_for_selector(".iso-svg [data-obj]", timeout=120000) + out["load_s"] = time.perf_counter() - t0 + pg.evaluate("window.__isokit_view = () => window.__isokit.view") + pg.wait_for_timeout(1000) + gestures(pg, cdp, pg.locator(".iso-stage"), out, wheel_mod="Control") + out["errors"] = errs + pg.close() + return out + + +ROWS = [ + ("load_s", "load (s)", "{:.2f}"), + ("open_house_s", "open house demo (s)", "{:.2f}"), + ("nodes", "SVG nodes", "{:.0f}"), + ("idle_busy", "idle: main thread busy", "{:.0%}"), + ("pan_ms_step", "pan: main thread ms per move", "{:.1f}"), + ("pan_busy", "pan: busy", "{:.0%}"), + ("pan_long", "pan: frames over 50 ms", "{:.0f}"), + ("pan_max_ms", "pan: longest frame (ms)", "{:.0f}"), + ("zoom_ms_step", "zoom: ms per wheel step", "{:.1f}"), + ("zoom_long", "zoom: frames over 50 ms", "{:.0f}"), + ("zoom_max_ms", "zoom: longest frame (ms)", "{:.0f}"), + ("hover_ms_step", "hover: ms per move", "{:.1f}"), + ("hover_long", "hover: frames over 50 ms", "{:.0f}"), + ("rotate_ms", "rotate until painted (ms)", "{:.0f}"), + ("level_ms", "level switch until painted (ms)", "{:.0f}"), + ("edit_ms", "edit until painted (ms)", "{:.0f}"), + ("select_ms", "select until painted (ms)", "{:.0f}"), +] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--rate", type=float, default=6.0) + ap.add_argument("--json") + ap.add_argument("--only", choices=["studio", "page"]) + ap.add_argument("--headed", action="store_true") + ap.add_argument("--anim", choices=["light", "smooth", "off"]) + a = ap.parse_args() + global ANIM + ANIM = a.anim + if not (DIST / "index.html").exists(): + sys.exit("no dist/web: run python tools/build_web.py first") + srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + res = {} + try: + time.sleep(0.8) + with sync_playwright() as p: + b = p.chromium.launch(channel="msedge", headless=not a.headed, args=["--window-position=0,0"]) + if a.only != "page": + res["studio"] = bench_studio(b, a.rate) + if a.only != "studio" and PAGE.exists(): + res["page"] = bench_page(b, a.rate) + b.close() + finally: + srv.terminate() + cols = list(res) + print(f"CPU slowed {a.rate:g}x, {'window' if a.headed else 'headless'}, animation {a.anim or 'default'}") + print(f"{'':34}" + "".join(f"{c:>12}" for c in cols)) + for k, lab, fmt in ROWS: + if any(k in res[c] for c in cols): + print(f"{lab:34}" + "".join(f"{(fmt.format(res[c][k]) if k in res[c] else '-'):>12}" for c in cols)) + for c in cols: + if res[c].get("errors"): + print(c, "errors:", res[c]["errors"]) + if a.json: + Path(a.json).write_text(json.dumps(res, indent=1)) + + +if __name__ == "__main__": + main() diff --git a/tools/check_studio.py b/tools/check_studio.py index fb160f7..212bb5a 100644 --- a/tools/check_studio.py +++ b/tools/check_studio.py @@ -345,6 +345,45 @@ with sync_playwright() as p: pg.wait_for_timeout(600) pg.evaluate("__studio.select(null)") + # performance: animations step about ten times a second by default (no CSS animation runs on its + # own), the toolbar button switches smooth / off / light; a pan moves the drawing as one layer and + # draws it again (viewBox) only when the drag ends + pg.evaluate("__studio.select(null)") + pg.evaluate("localStorage.removeItem('isokit-anim')") + an = lambda: pg.evaluate("__studio.view.animation()") + running = lambda: pg.evaluate("document.querySelector('.iso-svg').getAnimations({subtree: true}).filter(a => a.playState === 'running').length") + t0 = pg.evaluate("(() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : null; })()") + pg.wait_for_timeout(600) + t1 = pg.evaluate("(() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : null; })()") + check(an() == "light" and running() == 0 and t0 is not None and 300 < t1 - t0 < 900, + f"animations are light by default: none runs on its own, they step on ({t0} -> {t1})") + btn = pg.locator('.iso-bar [data-a="anim"]') + btn.click() + pg.wait_for_timeout(500) + check(an() == "smooth" and running() > 0 and "smooth" in btn.get_attribute("title"), f"the button switches to smooth ({running()} running)") + btn.click() + pg.wait_for_timeout(300) + check(an() == "off" and running() == 0, "then off") + btn.click() + pg.wait_for_timeout(300) + check(an() == "light" and pg.evaluate("localStorage.getItem('isokit-anim')") == "light", "then light again, remembered") + vb0 = pg.evaluate("document.querySelector('.iso-svg').getAttribute('viewBox')") + bx = pg.locator(".iso-stage").bounding_box() + cx2, cy2 = bx["x"] + bx["width"] / 2, bx["y"] + bx["height"] / 2 + pg.mouse.move(cx2, cy2) + pg.mouse.down(button="middle") + for i in range(1, 9): + pg.mouse.move(cx2 + 10 * i, cy2 + 4 * i) + pg.wait_for_timeout(20) + mid = pg.evaluate("[document.querySelector('.iso-svg').getAttribute('viewBox'), document.querySelector('.iso-svg').style.transform, document.querySelector('.iso-gesture') !== null]") + pg.mouse.up(button="middle") + pg.wait_for_timeout(300) + end = pg.evaluate("[document.querySelector('.iso-svg').getAttribute('viewBox'), document.querySelector('.iso-svg').style.transform, document.querySelector('.iso-gesture') !== null]") + check(mid[0] == vb0 and "translate" in mid[1] and mid[2] and end[0] != vb0 and end[1] == "" and not end[2], + f"a pan moves the layer (viewBox kept, transform {mid[1][:40]}), the release draws it again") + check(pg.evaluate("getComputedStyle(document.getElementById('cursor')).contain").startswith("strict") or "size" in pg.evaluate("getComputedStyle(document.getElementById('cursor')).contain"), + "the pointer readout is a contained box (its text does not lay the page out again)") + # the middle mouse button only moves the camera: it never drags, selects or places anything pg.locator('#objects li[data-id="truck"]').click() pg.evaluate("__studio.view.focus('truck', 3)") diff --git a/tools/check_web.py b/tools/check_web.py index 2162a63..4c426d2 100644 --- a/tools/check_web.py +++ b/tools/check_web.py @@ -300,6 +300,14 @@ try: check(pg.evaluate("() => __studio.S.warnings.length") == 0 and pg.locator('.st-tile[data-type="@room"]').count() == 1, "it draws without warnings, the palette has the Room tool") shot(pg, "web-house.jpg") + # low-power default: the pond's fish (and every other animation) step on about ten times a + # second instead of running as CSS animations that repaint the drawing every frame + an = pg.evaluate("""() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); + return [__studio.view.animation(), a.length, a.filter(x => x.playState === 'running').length, a.length ? a[0].currentTime : 0]; }""") + pg.wait_for_timeout(500) + t1 = pg.evaluate("() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : 0; }") + check(an[0] == "light" and an[1] >= 6 and an[2] == 0 and t1 > an[3] and pg.locator('.iso-bar [data-a="anim"]').is_visible(), + f"animations are light by default: {an[1]} stepped, none running, time moves on; the toolbar can switch") # ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder) demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8") diff --git a/viewer/isokit-view.css b/viewer/isokit-view.css index 92021bc..4622333 100644 --- a/viewer/isokit-view.css +++ b/viewer/isokit-view.css @@ -18,6 +18,9 @@ linear-gradient(180deg, var(--iso-sky1), var(--iso-sky2)); } .iso-svg { display: block; width: 100%; height: 100%; touch-action: pan-y; cursor: grab; user-select: none; -webkit-user-select: none; outline: none; } +/* its own compositor layer: pan and zoom gestures move it by a transform (no repaint) until they end; + drawn past its box so the ground just outside the view is there while panning (the stage clips) */ +.iso-svg { will-change: transform; transform-origin: 0 0; overflow: visible; } .iso-dragging .iso-svg { cursor: grabbing; } .iso-svg:focus-visible { outline: 2px solid var(--iso-accent); outline-offset: -2px; } .iso-bar { @@ -42,7 +45,8 @@ .iso-compass path { fill: var(--iso-accent); } .iso-compass b { font-size: 11px; } .iso-tip { - position: absolute; z-index: 3; max-width: min(330px, calc(100% - 12px)); padding: 8px 10px; pointer-events: none; + position: absolute; left: 0; top: 0; z-index: 3; max-width: min(330px, calc(100% - 12px)); padding: 8px 10px; pointer-events: none; + will-change: transform; contain: layout style; background: var(--iso-tipbg); color: var(--iso-tipfg); border: 1px solid var(--iso-tipline); border-radius: 6px; font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; box-shadow: 0 4px 14px rgba(0, 0, 0, .28); } @@ -112,8 +116,13 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; } .iso-svg .iso-swim { transform-box: fill-box; transform-origin: center; animation: iso-spin 20s linear infinite; } .iso-svg .iso-rip { transform-box: fill-box; transform-origin: center; animation: iso-rip 5.1s ease-out infinite; } .iso-svg .iso-park { display: none; } -.iso-off .iso-a, .iso-still .iso-a, .iso-off .iso-drive, .iso-still .iso-drive { animation-play-state: paused !important; } -.iso-still .iso-park { display: inline; } +/* paused off-screen (iso-off), when stopped (iso-still), with animation off and during pan/zoom gestures */ +.iso-off .iso-a, .iso-still .iso-a, .iso-off .iso-drive, .iso-still .iso-drive, .iso-off .iso-ledr, .iso-off .iso-ledo, .iso-still .iso-ledr, .iso-still .iso-ledo, +.iso-anim-off .iso-a, .iso-anim-off .iso-drive, .iso-anim-off .iso-ledr, .iso-anim-off .iso-ledo, +.iso-gesture .iso-a, .iso-gesture .iso-drive, .iso-gesture .iso-ledr, .iso-gesture .iso-ledo { animation-play-state: paused !important; } +.iso-still .iso-park, .iso-anim-off .iso-park { display: inline; } +.iso-animbtn { min-width: 30px; font-size: 13px; } +.iso-bar [hidden] { display: none !important; } @keyframes iso-spin { to { transform: rotate(360deg); } } @keyframes iso-puff { 0% { transform: translate(0, 0) scale(.35); opacity: 0; } @@ -136,7 +145,7 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; } 96%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; } } @media (prefers-reduced-motion: reduce) { - .iso-svg .iso-a { animation: none !important; } + .iso-svg .iso-a, .iso-svg .iso-ledr, .iso-svg .iso-ledo { animation: none !important; } .iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke, .iso-svg .iso-disc, .iso-svg .iso-glint, .iso-svg .iso-rip { display: none; } .iso-svg .iso-park { display: inline; } } diff --git a/viewer/isokit-view.js b/viewer/isokit-view.js index 659947f..ac85ab7 100644 --- a/viewer/isokit-view.js +++ b/viewer/isokit-view.js @@ -14,7 +14,7 @@ * view.update(cfg) view.refresh() view.setLevel(id) view.setRotation(r) view.rotate(d) * view.select(id) view.selectObj(id | [ids]) view.focus(id | [x, y, z], zoom) view.fit() * view.toWorld(clientX, clientY, z) -> [x, y] view.ready() -> Promise - * view.info(id) view.stats() view.fullscreen(on) view.destroy() + * view.info(id) view.stats() view.fullscreen(on) view.animation("light" | "smooth" | "off") view.destroy() */ (function (root) { "use strict"; @@ -89,6 +89,7 @@ '' + '' + '' + + '' + (opts.fullscreen === false ? "" : '') + "" + @@ -133,8 +134,62 @@ return undefined; } + // ---------------------------------------------------------- animation + // CSS animations inside the drawing repaint all of it every frame: a fifth of a fast core, + // all of a slow one. "light" (the default) pauses them and steps them on together about ten + // times a second, "smooth" lets them run, "off" stops them. Pan and zoom gestures pause them. + var ANIM = ["light", "smooth", "off"], ANIM_UI = { + light: ["\u25B7", "Animation: light, about 10 steps a second (click: smooth)"], + smooth: ["\u25B6", "Animation: smooth (click: off)"], + off: ["\u2016", "Animation: off (click: light)"] + }; + var animMode = (function () { + var m = null; + try { m = localStorage.getItem("isokit-anim"); } catch (e) { /* storage blocked */ } + return ANIM.indexOf(m) >= 0 ? m : ANIM.indexOf(opts.animation) >= 0 ? opts.animation : "light"; + })(); + var animTimer = 0, animLast = 0, animList = [], animBtn = bar.querySelector('[data-a="anim"]'); + var reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); + // after every new scene and every mode change + function animSetup() { + clearInterval(animTimer); animTimer = 0; animList = []; + wrap.classList.toggle("iso-anim-off", animMode === "off"); + if (animBtn) { + animBtn.hidden = reducedMotion || !svg.querySelector(".iso-a, .iso-drive, .iso-ledr, .iso-ledo"); + animBtn.textContent = ANIM_UI[animMode][0]; animBtn.title = ANIM_UI[animMode][1]; + animBtn.setAttribute("aria-label", ANIM_UI[animMode][1]); + } + if (animMode !== "light" || !svg.getAnimations) return; + animList = svg.getAnimations({ subtree: true }); + if (!animList.length) return; + animList.forEach(function (a) { a.pause(); }); + animLast = performance.now(); + animTimer = setInterval(animStep, 100); + } + function animStep() { + var now = performance.now(), dt = now - animLast; + animLast = now; + if (live || document.hidden || wrap.classList.contains("iso-off") || wrap.classList.contains("iso-still")) return; + for (var i = 0; i < animList.length; i++) animList[i].currentTime = (animList[i].currentTime || 0) + dt; + } + function setAnim(m) { + if (ANIM.indexOf(m) < 0 || m === animMode) return; + var was = animMode; + animMode = m; + try { localStorage.setItem("isokit-anim", m); } catch (e) { /* storage blocked */ } + // animations paused by script ignore CSS from then on: draw the scene again to let them run + if (was === "light" && m === "smooth" && scn) applyScene(scn); else animSetup(); + call(opts.onAnimation, m); + } + // ---------------------------------------------------------- build - function size() { var r = stage.getBoundingClientRect(); return [Math.max(1, r.width), Math.max(1, r.height)]; } + // the stage's box, read at most once per frame: reading it after a DOM write forces a layout + var rc = null; + function stageRect() { + if (!rc) { rc = stage.getBoundingClientRect(); (window.requestAnimationFrame || setTimeout)(function () { rc = null; }); } + return rc; + } + function size() { var r = stageRect(); return [Math.max(1, r.width), Math.max(1, r.height)]; } function build() { var my = ++seq; wrap.classList.add("iso-loading"); @@ -160,6 +215,7 @@ var hadScene = !!scn; scn = d; svg.innerHTML = d.html; + lastK = null; wrap.classList.toggle("iso-still", st.cfg.running === false); metas = Array.prototype.map.call(svg.querySelectorAll(".iso-tag"), function (g, i) { return { el: g, p: +g.getAttribute("data-p"), w: +g.getAttribute("data-w"), x: +g.getAttribute("data-x"), y: +g.getAttribute("data-y"), i: i, hid: false }; @@ -171,6 +227,7 @@ if (first || !hadScene) { first = false; lastSize = size(); focusDefault(); } else if (pendingFrame) { var f = pendingFrame; pendingFrame = null; f(); } else { var wasFit = atFit; computeFit(); if (wasFit) fit(); else { clampVB(); apply(); } } + animSetup(); call(opts.onRender, d); flush(); } @@ -182,11 +239,30 @@ fitW = w; return { x: b[0] + b[2] / 2 - w / 2, y: b[1] + b[3] / 2 - h / 2, w: w, h: h }; } + // Pan and zoom gestures move the drawing as one compositor layer (a CSS transform from the + // viewBox last drawn, vbC, to the view asked for, vb): no repaint and no style work per step. + // apply() commits vb as the viewBox (one repaint) when the gesture ends, and for every other change. + var vbC = null, live = false, commitT = 0, lastK = null; + function applyLive() { + if (!vb) return; + if (!vbC) { apply(); return; } + if (!live) wrap.classList.add("iso-gesture"); + live = true; + var s1 = size()[0] / vb.w, fitNow = vb.w >= fitW * 0.995; + // zoomed in, touch drags pan the view instead of the page (as apply() does) + if (fitNow !== atFit) { atFit = fitNow; svg.style.touchAction = atFit ? "pan-y" : "none"; } + svg.style.transform = "translate(" + ((vbC.x - vb.x) * s1).toFixed(2) + "px," + ((vbC.y - vb.y) * s1).toFixed(2) + "px) scale(" + (vbC.w / vb.w).toFixed(5) + ")"; + clearTimeout(commitT); commitT = setTimeout(apply, 180); + } function apply() { if (!vb) return; + clearTimeout(commitT); commitT = 0; + if (live) { live = false; svg.style.transform = ""; wrap.classList.remove("iso-gesture"); } + vbC = { x: vb.x, y: vb.y, w: vb.w }; svg.setAttribute("viewBox", r1(vb.x) + " " + r1(vb.y) + " " + r1(vb.w) + " " + r1(vb.h)); - var sz = size(); - svg.style.setProperty("--iso-k", (vb.w / sz[0]).toFixed(4)); + var sz = size(), k = (vb.w / sz[0]).toFixed(4); + // tags keep their screen size by --iso-k; set on their group alone, so the rest is not restyled + if (k !== lastK) { lastK = k; var tg = svg.querySelector(".iso-tags"); (tg || svg).style.setProperty("--iso-k", k); } atFit = vb.w >= fitW * 0.995; svg.style.touchAction = atFit ? "pan-y" : "none"; wrap.classList.toggle("iso-zoomed", !atFit); @@ -198,7 +274,7 @@ cx = Math.max(b[0], Math.min(b[0] + b[2], cx)); cy = Math.max(b[1], Math.min(b[1] + b[3], cy)); vb.x = cx - vb.w / 2; vb.y = cy - vb.h / 2; } - function zoomAt(f, px, py) { + function zoomAt(f, px, py, gesture) { if (!vb) return; touched = true; var sz = size(), nw = Math.max(fitW / 20, Math.min(fitW, vb.w / f)), nh = nw * sz[1] / sz[0]; @@ -206,10 +282,10 @@ var ux = vb.x + px * vb.w, uy = vb.y + py * vb.h; vb = { x: ux - px * nw, y: uy - py * nh, w: nw, h: nh }; if (nw >= fitW * 0.999) vb = computeFit(); else clampVB(); - apply(); + if (gesture) applyLive(); else apply(); } - function zoomClient(f, cx, cy) { var r = stage.getBoundingClientRect(); zoomAt(f, (cx - r.left) / r.width, (cy - r.top) / r.height); } - function panPx(dx, dy) { if (!vb) return; touched = true; var sz = size(), k = vb.w / sz[0]; vb.x -= dx * k; vb.y -= dy * k; clampVB(); apply(); } + function zoomClient(f, cx, cy, gesture) { var r = stageRect(); zoomAt(f, (cx - r.left) / r.width, (cy - r.top) / r.height, gesture); } + function panPx(dx, dy, gesture) { if (!vb) return; touched = true; var sz = size(), k = vb.w / sz[0]; vb.x -= dx * k; vb.y -= dy * k; clampVB(); if (gesture) applyLive(); else apply(); } function focusDefault() { fit(); var sz = size(); @@ -240,16 +316,31 @@ // ---------------------------------------------------------- picking function infoOf(id) { return (scn && scn.info && scn.info[id]) || { id: id, name: id, detail: "" }; } + // the tooltip: placed once per frame, in the frame's own callback (pointer handlers only note + // where); its text is rebuilt and measured only for another object, following the pointer only + // moves it (a transform on its own layer: no layout, no repaint of the drawing below) + var tipKey = null, tipW = 0, tipH = 0, tipNext = null, tipRaf = 0; function showTip(info, cx, cy) { - var rows = info.rows && info.rows.length ? "" + info.rows.map(function (r) { return ""; }).join("") + "
" + esc(r[0]) + "" + esc(r[1]) + "
" : ""; - tip.innerHTML = "" + esc(info.name) + "" + (info.detail ? "" + esc(info.detail) + "" : "") + rows; - tip.hidden = false; - var r = stage.getBoundingClientRect(), tw = tip.offsetWidth, th = tip.offsetHeight, x = cx - r.left + 14, y = cy - r.top + 14; - if (x + tw > r.width - 6) x = Math.max(6, cx - r.left - tw - 14); - if (y + th > r.height - 6) y = Math.max(6, cy - r.top - th - 14); - tip.style.left = x + "px"; tip.style.top = y + "px"; + tipNext = { info: info, cx: cx, cy: cy }; + if (!tipRaf) tipRaf = (window.requestAnimationFrame || setTimeout)(placeTip); } - function hideTip() { tip.hidden = true; } + function placeTip() { + tipRaf = 0; + var t = tipNext, info = t && t.info; + tipNext = null; + if (!t) return; + var rows = info.rows && info.rows.length ? "" + info.rows.map(function (r) { return ""; }).join("") + "
" + esc(r[0]) + "" + esc(r[1]) + "
" : ""; + var html = "" + esc(info.name) + "" + (info.detail ? "" + esc(info.detail) + "" : "") + rows; + if (tip.hidden || html !== tipKey) { + tip.innerHTML = html; tipKey = html; tip.hidden = false; + tipW = tip.offsetWidth; tipH = tip.offsetHeight; + } + var r = stageRect(), x = t.cx - r.left + 14, y = t.cy - r.top + 14; + if (x + tipW > r.width - 6) x = Math.max(6, t.cx - r.left - tipW - 14); + if (y + tipH > r.height - 6) y = Math.max(6, t.cy - r.top - tipH - 14); + tip.style.transform = "translate(" + Math.round(x) + "px," + Math.round(y) + "px)"; + } + function hideTip() { tipNext = null; tip.hidden = true; } function q(attr, id) { return svg.querySelectorAll("[" + attr + '="' + String(id).replace(/["\\]/g, "") + '"]'); } function select(id, quiet) { Array.prototype.forEach.call(svg.querySelectorAll(".iso-sel"), function (e) { e.classList.remove("iso-sel"); }); @@ -279,7 +370,7 @@ // client point -> world plan at height z function toWorld(cx, cy, z) { if (!vb) return null; - var r = stage.getBoundingClientRect(), sx = vb.x + (cx - r.left) / r.width * vb.w, sy = vb.y + (cy - r.top) / r.height * vb.h; + var r = stageRect(), sx = vb.x + (cx - r.left) / r.width * vb.w, sy = vb.y + (cy - r.top) / r.height * vb.h; return unproject(st.rot, sx, sy, z || 0); } function screenPx() { var sz = size(); return vb ? vb.w / sz[0] : 1; } @@ -312,7 +403,7 @@ p.x = e.clientX; p.y = e.clientY; if (pinch && npt() >= 2) { var k = Object.keys(ptrs), a = ptrs[k[0]], b = ptrs[k[1]], d = Math.hypot(a.x - b.x, a.y - b.y) || 1; - zoomClient(d / pinch.d, (a.x + b.x) / 2, (a.y + b.y) / 2); pinch.d = d; return; + zoomClient(d / pinch.d, (a.x + b.x) / 2, (a.y + b.y) / 2, true); pinch.d = d; return; } if (down && !moved && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 4) { moved = true; hideTip(); wrap.classList.add("iso-dragging"); @@ -320,7 +411,7 @@ } if (moved) { if (claimed) call(opts.interact.move, e, { world: toWorld(e.clientX, e.clientY, 0), dx: e.clientX - down.x, dy: e.clientY - down.y, k: screenPx() }); - else panPx(dx, dy); + else panPx(dx, dy, true); e.preventDefault(); } return; @@ -338,6 +429,7 @@ } function up(e) { var wasDown = down && down.id === e.pointerId; + if (live) apply(); delete ptrs[e.pointerId]; if (npt() < 2) pinch = null; if (wasDown) { @@ -355,7 +447,7 @@ svg.addEventListener("wheel", function (e) { if (!(e.ctrlKey || e.metaKey || opts.wheelZoom)) return; e.preventDefault(); - zoomClient(Math.exp(-e.deltaY * 0.004), e.clientX, e.clientY); + zoomClient(Math.exp(-e.deltaY * 0.004), e.clientX, e.clientY, true); }, { passive: false }); svg.addEventListener("dblclick", function (e) { e.preventDefault(); @@ -380,6 +472,7 @@ else if (a === "rotl") rotate(-1); else if (a === "rotr") rotate(1); else if (a === "zin") zoomAt(1.5); else if (a === "zout") zoomAt(1 / 1.5); else if (a === "fit") fit(); else if (a === "full") setFull(!isFull()); + else if (a === "anim") setAnim(ANIM[(ANIM.indexOf(animMode) + 1) % ANIM.length]); }); function setLevel(l) { @@ -402,6 +495,7 @@ } var ro = null, io = null; function onResize() { + rc = null; if (!scn) return; var sz = size(); if (Math.abs(sz[0] - lastSize[0]) < 0.5 && Math.abs(sz[1] - lastSize[1]) < 0.5) return; @@ -431,6 +525,8 @@ setSource: function (s) { source = s; build(); return view; }, setLevel: function (l) { setLevel(l); return view; }, getLevel: function () { return st.level; }, + // "light" | "smooth" | "off"; without an argument: the current mode + animation: function (m) { if (m === undefined) return animMode; setAnim(m); return view; }, rotate: function (d) { rotate(d); return view; }, setRotation: function (r) { var d = rotOf(r - st.rot); if (d) rotate(d); return view; }, getRotation: function () { return st.rot; }, @@ -467,7 +563,7 @@ ready: function () { return new Promise(function (res) { if (!wrap.classList.contains("iso-loading") && scn) res(view); else waiting.push(function () { res(view); }); }); }, info: function (id) { return id ? infoOf(id) : Object.keys((scn && scn.info) || {}).map(infoOf); }, config: function () { return Object.assign({}, st.cfg); }, - stats: function () { return { nodes: svg.querySelectorAll("*").length, sprites: scn ? scn.sprites : 0, level: st.level, rotation: st.rot, ms: scn ? scn.ms : 0 }; }, + stats: function () { return { nodes: svg.querySelectorAll("*").length, sprites: scn ? scn.sprites : 0, level: st.level, rotation: st.rot, ms: scn ? scn.ms : 0, animation: animMode, animations: animList.length }; }, destroy: function () { alive = false; if (ro) ro.disconnect(); else window.removeEventListener("resize", onResize); @@ -476,6 +572,7 @@ document.removeEventListener("webkitfullscreenchange", fsSync); document.removeEventListener("keydown", onFsKey); if (io) io.disconnect(); + clearInterval(animTimer); if (wrap.parentNode) wrap.parentNode.removeChild(wrap); } }; diff --git a/viewer/page.html b/viewer/page.html index 3d2f4af..893932c 100644 --- a/viewer/page.html +++ b/viewer/page.html @@ -48,6 +48,7 @@ document.documentElement.setAttribute("data-theme",t);})(); function esc(s) { return String(s).replace(/&/g, "&").replace(//g, ">"); } var view = IsoKit.mount(document.getElementById("view"), IsoKit.staticSource(data), { fullscreen: document.querySelector(".card"), + animation: data.animation || undefined, onPick: function (i) { if (!i) { picked.textContent = "Tap or click anything for its details."; return; } picked.innerHTML = "" + esc(i.name) + "" + (i.detail ? "
" + esc(i.detail) : "") +