From 4721cf8666f6055fb4162f093e2050459e4e849d Mon Sep 17 00:00:00 2001 From: andrinr Date: Fri, 23 Oct 2026 11:39:55 +0200 Subject: [PATCH] Public demo: the playground's viewport over the shipped scenes, with no Python static.html grows from a scene dropdown into an editable static demo that GitHub Pages can host: - The playground's ViewerPane, object tree, render panel, view cube and design language, fixed into a three-column desk. No editor, Simulate or Optimize. - A parameter panel: slider and field per free parameter, grouped by the object whose handle moves it. An edit writes the parameter buffer at once, then installs shaders rebuilt at the new values, a values-only upload. - The construction overlay from exported compile data. Handles bound to a free parameter follow it, and dragging one edits it. Constraint-driven parameters are read-only, drawn held, and name the constraint. - Wireframe and feature edges from dual contouring on jax-js; the edges are a dihedral crease test, not the Python lattice classifier (not ported). - STL export of the current values at a chosen resolution. - WebGPU check with a message; jax-js on WebGPU or Wasm; public-tier tags. Data: cadjoint/viewer/schema/demo.py (DemoScene, emitted to payloads.d.ts), written by `python -m cadjoint.viewer.demo` into frontend/src/static/bundle, each scene run in a fresh process because unnamed primitives are numbered per process. tests/viewer/test_demo_bundle.py keeps the bundle current. Shared changes: ViewerPane takes an optional lockedHandle (absent in the playground, so behaviour there is unchanged); tier.ts's setter is stateTier. .github/workflows/demo.yml builds the demo and publishes to gh-pages/demo only when started by hand with deploy ticked. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 + .github/workflows/demo.yml | 84 +++ README.md | 3 + cadjoint/viewer/demo.py | 306 ++++++++++ cadjoint/viewer/schema/constants.ts | 6 + cadjoint/viewer/schema/demo.py | 86 +++ cadjoint/viewer/schema/emit.py | 11 +- cadjoint/viewer/schema/payloads.d.ts | 38 ++ ...{design-B0kjMxCa.js => design-Bd756-vr.js} | 2 +- .../{main-CWRSwsGN.js => main-0Bz4AUvN.js} | 56 +- .../viewer/static/assets/panels-B9sqm32f.css | 1 - .../viewer/static/assets/panels-BhdWhxca.css | 1 + ...{panels-B0c2leWX.js => panels-DNNFhkG6.js} | 0 cadjoint/viewer/static/design.html | 6 +- cadjoint/viewer/static/index.html | 6 +- frontend/e2e/static/demo.spec.ts | 77 +++ frontend/package.json | 2 + frontend/playwright.config.ts | 2 + frontend/playwright.static.config.ts | 40 ++ frontend/src/components/ViewerPane.tsx | 23 +- frontend/src/components/viewer/ViewerHint.tsx | 5 +- frontend/src/components/viewer/props.ts | 12 + frontend/src/static/DemoApp.tsx | 552 ++++++++++++++++++ frontend/src/static/ParameterPanel.tsx | 191 ++++++ frontend/src/static/README.md | 71 ++- frontend/src/static/bundle.ts | 40 ++ frontend/src/static/bundle/bracket.demo.json | 1 + .../static/bundle/cylinder_block.demo.json | 1 + .../src/static/bundle/duct_fairing.demo.json | 1 + .../src/static/bundle/duct_sink.demo.json | 1 + frontend/src/static/bundle/end_cap.demo.json | 1 + .../src/static/bundle/motor_shield.demo.json | 1 + frontend/src/static/bundle/public-tier.json | 1 + frontend/src/static/bundle/starter.demo.json | 1 + frontend/src/static/demo.css | 214 +++++++ frontend/src/static/edges.ts | 64 ++ frontend/src/static/jaxjs.ts | 41 +- frontend/src/static/main.ts | 131 ----- frontend/src/static/main.tsx | 17 + frontend/src/static/parameters.ts | 294 ++++++++++ frontend/src/static/scenes/bracket.scene.json | 1 - .../static/scenes/cylinder_block.scene.json | 1 - .../src/static/scenes/duct_fairing.scene.json | 1 - .../src/static/scenes/duct_sink.scene.json | 1 - frontend/src/static/scenes/end_cap.scene.json | 1 - .../src/static/scenes/motor_shield.scene.json | 1 - frontend/src/styles.css | 6 +- frontend/src/tier.ts | 4 +- frontend/static.html | 22 +- frontend/test/demoBundle.test.ts | 73 +++ frontend/test/demoParameters.test.ts | 147 +++++ frontend/test/tier.test.ts | 8 +- frontend/vite.static.config.ts | 21 +- tests/viewer/test_demo_bundle.py | 96 +++ 54 files changed, 2542 insertions(+), 236 deletions(-) create mode 100644 .github/workflows/demo.yml create mode 100644 cadjoint/viewer/demo.py create mode 100644 cadjoint/viewer/schema/demo.py rename cadjoint/viewer/static/assets/{design-B0kjMxCa.js => design-Bd756-vr.js} (99%) rename cadjoint/viewer/static/assets/{main-CWRSwsGN.js => main-0Bz4AUvN.js} (91%) delete mode 100644 cadjoint/viewer/static/assets/panels-B9sqm32f.css create mode 100644 cadjoint/viewer/static/assets/panels-BhdWhxca.css rename cadjoint/viewer/static/assets/{panels-B0c2leWX.js => panels-DNNFhkG6.js} (100%) create mode 100644 frontend/e2e/static/demo.spec.ts create mode 100644 frontend/playwright.static.config.ts create mode 100644 frontend/src/static/DemoApp.tsx create mode 100644 frontend/src/static/ParameterPanel.tsx create mode 100644 frontend/src/static/bundle.ts create mode 100644 frontend/src/static/bundle/bracket.demo.json create mode 100644 frontend/src/static/bundle/cylinder_block.demo.json create mode 100644 frontend/src/static/bundle/duct_fairing.demo.json create mode 100644 frontend/src/static/bundle/duct_sink.demo.json create mode 100644 frontend/src/static/bundle/end_cap.demo.json create mode 100644 frontend/src/static/bundle/motor_shield.demo.json create mode 100644 frontend/src/static/bundle/public-tier.json create mode 100644 frontend/src/static/bundle/starter.demo.json create mode 100644 frontend/src/static/demo.css create mode 100644 frontend/src/static/edges.ts delete mode 100644 frontend/src/static/main.ts create mode 100644 frontend/src/static/main.tsx create mode 100644 frontend/src/static/parameters.ts delete mode 100644 frontend/src/static/scenes/bracket.scene.json delete mode 100644 frontend/src/static/scenes/cylinder_block.scene.json delete mode 100644 frontend/src/static/scenes/duct_fairing.scene.json delete mode 100644 frontend/src/static/scenes/duct_sink.scene.json delete mode 100644 frontend/src/static/scenes/end_cap.scene.json delete mode 100644 frontend/src/static/scenes/motor_shield.scene.json create mode 100644 frontend/test/demoBundle.test.ts create mode 100644 frontend/test/demoParameters.test.ts create mode 100644 tests/viewer/test_demo_bundle.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e6eb9b4..799e8d88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,11 @@ jobs: - name: Build run: npm run build + # The public demo is a second page built from the same source; it is + # not committed, so building it is the whole check. + - name: Build the static demo + run: npm run build:static + # `pip install cadjoint` ships cadjoint/viewer/static as committed, and # the playground, the e2e specs and the design linter all serve it, so a # source change committed without its rebuilt bundle is a release that diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 00000000..57f98553 --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,84 @@ +name: demo + +# The public demo (frontend/static.html) as plain files: no server, so GitHub +# Pages can host it. Nothing triggers this on its own: it runs only when +# someone starts it from the Actions tab, and it publishes only when that +# run is started with `deploy` ticked. +# +# Where it publishes: this repository's Pages site is the docs, which +# docs.yml renders with Quarto and pushes to the gh-pages branch on every +# push to main. The demo goes into that branch's demo/ directory, so it is +# served at /demo/ beside the docs. Quarto's gh-pages publish +# replaces the branch's contents, so a later docs deploy removes demo/ +# again; to keep the demo up for good, build it in docs.yml and copy +# frontend/dist-static into _site/demo before the publish step instead +# (frontend/src/static/README.md). +on: + workflow_dispatch: + inputs: + deploy: + description: "Publish the build to gh-pages/demo (otherwise it is only uploaded as an artifact)" + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: demo + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install cadjoint + run: | + # The same jax cap as ci.yml, as a constraint so later installs keep it. + printf 'jax<0.9\njaxlib<0.9\n' > "$RUNNER_TEMP/jax-cap.txt" + export PIP_CONSTRAINT="$RUNNER_TEMP/jax-cap.txt" + pip install "jax[cpu]>=0.4.20,<0.9" + pip install -e ".[dev]" --no-deps + pip install scikit-image matplotlib optax optimistix pydantic + + # The bundle is committed, but the deployed demo is rebuilt from the + # scenes on this ref so it can never show a stale scene. + - name: Write the scene bundle from the shipped scenes + run: python -m cadjoint.viewer.demo + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build the demo + working-directory: frontend + run: | + npm ci --no-audit --no-fund + npm run build:static + + - uses: actions/upload-artifact@v4 + with: + name: demo + path: frontend/dist-static + + - name: Publish to gh-pages/demo + if: inputs.deploy + run: | + git fetch --depth 1 origin gh-pages + git worktree add --detach "$RUNNER_TEMP/pages" origin/gh-pages + rm -rf "$RUNNER_TEMP/pages/demo" + cp -r frontend/dist-static "$RUNNER_TEMP/pages/demo" + cd "$RUNNER_TEMP/pages" + git add -A demo + git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -m "demo: build of ${GITHUB_SHA::7}" || { echo "No change to publish."; exit 0; } + git push origin HEAD:gh-pages diff --git a/README.md b/README.md index e5e957c4..16383973 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,9 @@ The playground UI is a Solid + TypeScript app in `frontend/`, built into `cadjoint/viewer/static` and committed, so installing cadjoint needs no Node toolchain; its workflow is in [developing the UI](https://andrinr.github.io/cadjoint/docs/viewer.html#developing-the-ui). +The same app also builds as a static public demo with no Python server +(`npm run dev:static` in `frontend/`); see +[`frontend/src/static/README.md`](frontend/src/static/README.md). Docs need [Quarto](https://quarto.org/docs/get-started/) and the `docs` extra: `uv run quartodoc build`, then `quarto preview`. `benchmarks/jax_compile_profile.py` splits a worker request into trace, lower, diff --git a/cadjoint/viewer/demo.py b/cadjoint/viewer/demo.py new file mode 100644 index 00000000..6f71aa61 --- /dev/null +++ b/cadjoint/viewer/demo.py @@ -0,0 +1,306 @@ +"""Write the static demo's bundle from the shipped scenes. + +``python -m cadjoint.viewer.demo`` runs every scene in ``scenes/`` through the +compile worker once and keeps what the browser needs to draw, edit and export +it without Python (:class:`~cadjoint.viewer.schema.demo.DemoScene`), plus the +public tier's capabilities, which the page shows beside the features the +private tier would make exact. ``--check`` compares instead of writing, for +a test that keeps the committed bundle the build of the committed scenes. + +A scene whose ``scene`` has no ``cadjoint-scene`` graph cannot be drawn by the +browser and is left out, with a note. + +Every run of a scene gets a fresh process, as it does in the playground, +whose worker is one process per compile. Some names depend on what the +process ran before: a primitive declared without a name is numbered by a +per-process counter (``cylinder``, ``cylinder_2``), and its parameters are +named after it, so a second run in the same process names them differently. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import math +import multiprocessing +import re +import sys +from collections.abc import Iterable +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path +from typing import Any + +from cadjoint import extract_parameters +from cadjoint.plugins import PluginRegistry +from cadjoint.tier import label, message, status +from cadjoint.viewer.schema.api import CapabilitiesResponse +from cadjoint.viewer.schema.demo import DEMO_FORMAT, DEMO_VERSION, DemoParameter, DemoScene +from cadjoint.viewer.worker.main import HANDLERS +from cadjoint.viewer.worker.scene import _execute_scene + +ROOT = Path(__file__).resolve().parents[2] +SCENES = ROOT / "scenes" +BUNDLE = ROOT / "frontend" / "src" / "static" / "bundle" +TIER_FILE = "public-tier.json" + + +def _docstring(source: str) -> tuple[str, str]: + """The module docstring's first line, and its first paragraph after that.""" + text = ast.get_docstring(ast.parse(source)) or "" + head, _, rest = text.partition("\n") + paragraph = rest.strip().split("\n\n")[0] + + def plain(text: str) -> str: + """Prose without reST literals and emphasis.""" + return re.sub(r"``(.+?)``|\*(.+?)\*", lambda m: m[1] or m[2], " ".join(text.split())) + + return plain(head).rstrip("."), plain(paragraph) + + +def _points(construction: Iterable[dict[str, Any]]) -> list[list[float]]: + points: list[list[float]] = [] + for node in construction: + points.extend(point for edge in node["edges"] for point in edge) + points.extend(vertex["world"] for vertex in node["vertices"]) + return points + + +def _extent(construction: list[dict[str, Any]]) -> float: + """The diagonal of the construction's bounding box, or 1 for an empty one.""" + points = _points(construction) + if not points: + return 1.0 + return math.dist([min(c) for c in zip(*points)], [max(c) for c in zip(*points)]) or 1.0 + + +def _step(span: float) -> float: + """A power of ten near a hundredth of ``span``: what a slider moves by.""" + return 10.0 ** math.floor(math.log10(span / 100.0)) if span > 0 else 0.01 + + +def _snap(value: float, step: float, up: bool) -> float: + # Rounded first: the values are float32, and 2 x 0.2f must not snap up to 0.401. + snapped = (math.ceil if up else math.floor)(round(value / step, 4)) * step + return round(snapped, 12) + + +def _range(value: list[float], extent: float) -> tuple[list[float], list[float], float]: + """A slider range around a shipped value that declared no bounds. + + A positive scalar is a length (a thickness, a radius): it may shrink to a + quarter and grow to twice. Anything else is a coordinate, and moves half + the scene's size either way. + """ + if len(value) == 1 and value[0] > 0: + lower, upper = [value[0] / 4], [value[0] * 2] + else: + lower = [v - extent / 2 for v in value] + upper = [v + extent / 2 for v in value] + step = _step(max(u - lo for lo, u in zip(lower, upper))) + lower = [min(_snap(lo, step, False), v) for lo, v in zip(lower, value)] + upper = [max(_snap(u, step, True), v) for u, v in zip(upper, value)] + return lower, upper, step + + +def _constraint_text(constraint: Any) -> str: + names = [p.name or f"{float(p.value):g}" for p in constraint.get_parameters()] + return f"{type(constraint).__name__}({', '.join(names)})" + + +def _owners(construction: list[dict[str, Any]]) -> dict[str, str]: + """Parameter name → the construction object whose handle moves it.""" + owners: dict[str, str] = {} + for node in construction: + for vertex in node["vertices"]: + if vertex.get("binding"): + owners.setdefault(vertex["binding"]["name"], node["id"]) + for bindings in ((node.get("transform") or {}).get("bindings") or {}).values(): + for binding in bindings: + owners.setdefault(binding["name"], node["id"]) + return owners + + +def _parameters( + program: dict[str, Any], declared: dict[str, dict[str, Any]], construction: list[dict[str, Any]] +) -> list[DemoParameter]: + """Every free slot of the shader, with its range and what holds it.""" + extent = _extent(construction) + owners = _owners(construction) + entries: list[DemoParameter] = [] + for slot in program["parameters"]: + if not slot["free"] or any(v is None for v in slot["value"]): + continue + value = [float(v) for v in slot["value"]] + facts = declared.get(slot["name"], {}) + bounds = facts.get("bounds") + if bounds is not None: + lower = [float(bounds[0])] * len(value) + upper = [float(bounds[1])] * len(value) + step = _step(max(u - lo for lo, u in zip(lower, upper))) + else: + lower, upper, step = _range(value, extent) + entries.append( + DemoParameter( + name=slot["name"], + components=slot["components"], + value=value, + lower=lower, + upper=upper, + step=step, + bounded=bounds is not None, + owner=owners.get(slot["name"]), + drivenBy=facts.get("drivenBy"), + ) + ) + return entries + + +def _compiled(source: str) -> dict[str, Any]: + """The compile payload of ``source``: run in a fresh process.""" + return HANDLERS["compile"]({"source": source}) + + +def _declared(source: str) -> dict[str, dict[str, Any]]: + """Per free parameter, its declared bounds and the constraints holding it. + + The compile payload names the parameters; these facts are on the + Parameter objects, which only a run of the scene has. Run in a fresh + process. + """ + _, _, metadata = extract_parameters(_execute_scene(source)["scene"]) + return { + name: { + "bounds": [float(b) for b in parameter.bounds] if parameter.bounds else None, + "drivenBy": "; ".join(_constraint_text(c) for c in parameter.get_constraints()) or None, + } + for name, parameter in metadata.items() + } + + +def _record( + path: Path, payload: dict[str, Any], declared: dict[str, dict[str, Any]] +) -> DemoScene | None: + """The demo's record of one scene, from its two runs.""" + if not payload.get("ok"): + raise RuntimeError(f"{path.name} did not compile:\n{payload.get('error')}") + if payload["scene_graph"] is None or payload["program"] is None: + print(f"note: {path.name} has no scene graph; the demo leaves it out", file=sys.stderr) + return None + source = path.read_text() + title, description = _docstring(source) + # Faces are what the sketch-on-face tool picks from, and the demo has no + # such tool; they are most of a construction payload's size. + construction = [{**node, "faces": []} for node in payload["construction"]] + return DemoScene( + format=DEMO_FORMAT, + version=DEMO_VERSION, + name=path.stem, + title=title, + description=description, + scene_graph=payload["scene_graph"], + parameters=_parameters(payload["program"], declared, construction), + construction=construction, + elements=payload["elements"], + scene_tree=payload["scene_tree"], + relations=payload["relations"], + ) + + +def demo_scenes(paths: list[Path], workers: int = 4) -> dict[Path, DemoScene | None]: + """Each scene's record, every run of a scene in a process of its own.""" + with ProcessPoolExecutor( + max_workers=workers, + mp_context=multiprocessing.get_context("spawn"), + max_tasks_per_child=1, + ) as pool: + runs = { + path: ( + pool.submit(_compiled, path.read_text()), + pool.submit(_declared, path.read_text()), + ) + for path in paths + } + return { + path: _record(path, compiled.result(), declared.result()) + for path, (compiled, declared) in runs.items() + } + + +def demo_scene(path: Path) -> DemoScene | None: + """The demo's record of one scene file, or ``None`` if the browser cannot draw it.""" + return demo_scenes([path], workers=2)[path] + + +def public_tier() -> CapabilitiesResponse: + """What :mod:`cadjoint.tier` reports with nothing filling the private kinds.""" + report = status(PluginRegistry()).as_dict() + for kind, entry in report["kinds"].items(): + entry["label"] = label(kind, False) + entry["message"] = message(kind, entry["reason"]) + return CapabilitiesResponse(ok=True, **report) + + +def _dump(model: Any) -> str: + return json.dumps(model.model_dump(mode="json"), indent=None, separators=(",", ":")) + "\n" + + +def bundle_files(scenes: Path = SCENES) -> dict[str, str]: + """File name → contents of the whole bundle.""" + files = {TIER_FILE: _dump(public_tier())} + for path, scene in demo_scenes(sorted(scenes.glob("*.py"))).items(): + if scene is not None: + files[f"{path.stem}.demo.json"] = _dump(scene) + return files + + +def same(a: Any, b: Any) -> bool: + """Whether two bundle documents agree: in shape exactly, in floats to float32 round-off. + + A scene's numbers come out of JAX, and XLA may fuse a multiply-add + differently from one process to the next (a warm compilation cache, a + test worker that compiled the program first), which moves the last bits + of a float32 and nothing else. + """ + if isinstance(a, float) or isinstance(b, float): + return ( + isinstance(a, int | float) + and isinstance(b, int | float) + and math.isclose(a, b, rel_tol=1e-5, abs_tol=1e-6) + ) + if isinstance(a, dict) and isinstance(b, dict): + return a.keys() == b.keys() and all(same(a[k], b[k]) for k in a) + if isinstance(a, list) and isinstance(b, list): + return len(a) == len(b) and all(same(x, y) for x, y in zip(a, b)) + return a == b + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m cadjoint.viewer.demo", description=__doc__) + parser.add_argument("--out", type=Path, default=BUNDLE, help="bundle directory") + parser.add_argument("--check", action="store_true", help="fail if the bundle is stale") + args = parser.parse_args(argv) + files = bundle_files() + existing = {p.name for p in args.out.glob("*.json")} if args.out.exists() else set() + if args.check: + stale = sorted( + name + for name in set(files) | existing + if name not in files + or not (args.out / name).exists() + or not same(json.loads((args.out / name).read_text()), json.loads(files[name])) + ) + for name in stale: + print(f"stale: {args.out / name}", file=sys.stderr) + return 1 if stale else 0 + args.out.mkdir(parents=True, exist_ok=True) + for name in existing - set(files): + (args.out / name).unlink() + for name, text in files.items(): + (args.out / name).write_text(text) + print(f"wrote {len(files)} files to {args.out}") + return 0 + + +if __name__ == "__main__": # pragma: no cover - a developer command + raise SystemExit(main()) diff --git a/cadjoint/viewer/schema/constants.ts b/cadjoint/viewer/schema/constants.ts index 6cff10d0..ff06a408 100644 --- a/cadjoint/viewer/schema/constants.ts +++ b/cadjoint/viewer/schema/constants.ts @@ -42,3 +42,9 @@ export const SCENE_GRAPH = { format: "cadjoint-scene", versions: [1, 2], } as const; + +/** The static demo's scene files (`python -m cadjoint.viewer.demo`). */ +export const DEMO_BUNDLE = { + format: "cadjoint-demo", + version: 1, +} as const; diff --git a/cadjoint/viewer/schema/demo.py b/cadjoint/viewer/schema/demo.py new file mode 100644 index 00000000..d9a2d605 --- /dev/null +++ b/cadjoint/viewer/schema/demo.py @@ -0,0 +1,86 @@ +"""The static demo's bundle: what a page with no Python server reads. + +The public demo (``frontend/static.html``) renders, edits and exports the +shipped scenes in the browser alone. It cannot run a scene, so everything a +compile would have told it is written at build time by +:mod:`cadjoint.viewer.demo`, one :class:`DemoScene` per scene, and checked +against these models on the way out. The frontend reads the TypeScript +emitted from them (``payloads.d.ts``), like every other payload. + +A demo scene is a slice of the compile payload — the graph the browser +builds its WGSL from, and the construction data the overlay draws — plus the +one thing a compile never had to say: which free parameters the page may +move on its own. A parameter some constraint holds needs the constraint +solver to move, and the solver is Python; such a parameter is listed with +the constraint that holds it and stays at its shipped value. + +The construction nodes are the compile payload's with their ``faces`` left +empty: faces are what the sketch-on-face tool picks from, and the demo has +no such tool. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field + +from cadjoint.viewer.schema.payloads import ( + ConstructionElement, + ConstructionNode, + ConstructionRelation, + SceneGraph, + SceneTree, + Strict, +) + +DEMO_FORMAT = "cadjoint-demo" +"""The ``format`` every demo scene file carries.""" + +DEMO_VERSION = 1 +"""The version :mod:`cadjoint.viewer.demo` writes and the page reads.""" + + +class DemoParameter(Strict): + """One free design parameter, as the demo's parameter panel offers it. + + ``name``, ``components`` and ``value`` are the shader slot's + (:class:`~cadjoint.viewer.schema.payloads.ShaderParameter`), so a value + the panel sets is a buffer write under the same name. ``lower`` and + ``upper`` bound each component; ``bounded`` says whether they are the + parameter's declared ``bounds`` or a range the exporter chose around the + shipped value. + """ + + name: str + components: int = Field(ge=1, le=4) + value: list[float] + lower: list[float] + upper: list[float] + step: float = Field(gt=0) + bounded: bool + # The unit the source states, when it states one. cadjoint parameters + # carry none today, so this is null for every shipped scene. + unit: str | None = None + # The construction object whose handle moves this value, when one does. + owner: str | None + # The constraint holding this parameter, written out (``"DistanceConstraint + # (rib_web_corner, rib_tip, rib_height)"``); ``None`` when nothing holds it + # and the page may move it. + drivenBy: str | None + + +class DemoScene(Strict): + """Everything the demo needs about one shipped scene.""" + + format: Literal["cadjoint-demo"] + version: Literal[1] + name: str + title: str + description: str + scene_graph: SceneGraph + parameters: list[DemoParameter] + construction: list[ConstructionNode] + elements: list[ConstructionElement] + scene_tree: SceneTree | None + relations: list[ConstructionRelation] diff --git a/cadjoint/viewer/schema/emit.py b/cadjoint/viewer/schema/emit.py index 4ea1d6b8..95d3dab5 100644 --- a/cadjoint/viewer/schema/emit.py +++ b/cadjoint/viewer/schema/emit.py @@ -23,7 +23,7 @@ from pydantic.json_schema import models_json_schema from cadjoint.enums import BoundaryConditionType -from cadjoint.viewer.schema import api, payloads, requests +from cadjoint.viewer.schema import api, demo, payloads, requests TYPESCRIPT_PATH = Path(__file__).with_name("payloads.d.ts") """The generated file the frontend imports.""" @@ -158,6 +158,9 @@ requests.SidePlaneReference, requests.FacePlaneReference, requests.TangentPlaneReference, + # ── the static demo's bundle (demo.py) ── + demo.DemoScene, + demo.DemoParameter, ) @@ -414,6 +417,7 @@ def constants_source() -> str: what the server accepts. The scene shader's parameter-buffer layout and the scene graph format, so that WGSL built in the browser (``frontend/src/scene``) lays out the buffer the worker's WGSL does. + The static demo's file format, so the page refuses a bundle it cannot read. """ from cadjoint.backends import jaxjs from cadjoint.backends.wgsl import codegen @@ -461,6 +465,11 @@ def strings(values: Any) -> str: "export const SCENE_GRAPH = {\n" f' format: "{jaxjs.SCENE_FORMAT}",\n' f" versions: {list(jaxjs.SCENE_VERSIONS)},\n" + "} as const;\n\n" + "/** The static demo's scene files (`python -m cadjoint.viewer.demo`). */\n" + "export const DEMO_BUNDLE = {\n" + f' format: "{demo.DEMO_FORMAT}",\n' + f" version: {demo.DEMO_VERSION},\n" "} as const;\n" ) diff --git a/cadjoint/viewer/schema/payloads.d.ts b/cadjoint/viewer/schema/payloads.d.ts index 85f6190c..f4cd4a5f 100644 --- a/cadjoint/viewer/schema/payloads.d.ts +++ b/cadjoint/viewer/schema/payloads.d.ts @@ -1540,6 +1540,44 @@ export interface TangentPlaneReference { near: [number, number, number]; } +/** Everything the demo needs about one shipped scene. */ +export interface DemoScene { + format: "cadjoint-demo"; + version: 1; + name: string; + title: string; + description: string; + scene_graph: SceneGraph; + parameters: DemoParameter[]; + construction: ConstructionNode[]; + elements: ConstructionElement[]; + scene_tree: SceneTree | null; + relations: ConstructionRelation[]; +} + +/** + * One free design parameter, as the demo's parameter panel offers it. + * + * ``name``, ``components`` and ``value`` are the shader slot's + * (:class:`~cadjoint.viewer.schema.payloads.ShaderParameter`), so a value + * the panel sets is a buffer write under the same name. ``lower`` and + * ``upper`` bound each component; ``bounded`` says whether they are the + * parameter's declared ``bounds`` or a range the exporter chose around the + * shipped value. + */ +export interface DemoParameter { + name: string; + components: number; + value: number[]; + lower: number[]; + upper: number[]; + step: number; + bounded: boolean; + unit?: string | null; + owner: string | null; + drivenBy: string | null; +} + /** * The boundary conditions a study accepts, as the wire spells them. * diff --git a/cadjoint/viewer/static/assets/design-B0kjMxCa.js b/cadjoint/viewer/static/assets/design-Bd756-vr.js similarity index 99% rename from cadjoint/viewer/static/assets/design-B0kjMxCa.js rename to cadjoint/viewer/static/assets/design-Bd756-vr.js index d15ee267..8a76328e 100644 --- a/cadjoint/viewer/static/assets/design-B0kjMxCa.js +++ b/cadjoint/viewer/static/assets/design-Bd756-vr.js @@ -1 +1 @@ -import{C as G,d as me,i as n,c as t,S as H,a as y,s as h,b as j,e as mn,f as Vt,F as C,g as N,t as l,h as Se,j as J,k as F,l as Gt,o as ze,r as vn,L as gn,m as pn,n as ve,p as Q,u as qt,q as bn,v as $n,w as ie,x as Ut,y as Yt,z as Ce,B as fn,P as yn,A as Be,D as he,E as Xt,M as He,G as Ke,H as Ve,I as _n,J as Jt,K as wn,N as xn,O as kn,R as Sn,Q as Cn,T as An,U as En,V as Tn,W as On,X as Mn,Y as In,Z as Ln,_ as Nn,$ as Dn,a0 as Zt,a1 as Rn,a2 as jn,a3 as Pn,a4 as Fe,a5 as Te,a6 as Fn,a7 as Wn,a8 as zn,a9 as Qt,aa as Bn,ab as Hn,ac as nt,ad as lt,ae as Kn}from"./panels-B0c2leWX.js";var Vn=l("

"),Gn=l("

"),qn=l("

"),Un=l("

"),Yn=l("

"),Xn=l("

·
"),Jn=l(":1"),Zn=l(""),Qn=l("
ground sheet "),el=l(''),tl=l("

Each choice restyles the whole page. Download keeps the selection and every resolved value; tokens.ts can be set from the file.