From e3c234cabfaf294501aeb9e934f196bb0f153310 Mon Sep 17 00:00:00 2001 From: Seth Parker Date: Fri, 2 Oct 2026 11:45:08 -0400 Subject: [PATCH 1/2] Add --preview HTML viewer to scrollcase build `scrollcase build --preview` writes -preview.html next to the STLs and opens it. The page embeds every part as one GLB and renders it with three.js from a CDN, with per-part visibility, color and opacity, an explode slider, upright/on-stand poses, a grid toggle, a background color, and a PNG snapshot of the view. trimesh moves from a dev dependency to a runtime one. Co-Authored-By: Claude Opus 5.5 --- docs/creating-scroll-case.md | 7 +- scrollcase/README.md | 5 +- scrollcase/pyproject.toml | 3 +- scrollcase/src/scrollcase/cli.py | 14 +- scrollcase/src/scrollcase/preview.html | 235 +++++++++++++++++++++++++ scrollcase/src/scrollcase/preview.py | 45 +++++ scrollcase/tests/test_preview.py | 33 ++++ scrollcase/uv.lock | 8 +- 8 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 scrollcase/src/scrollcase/preview.html create mode 100644 scrollcase/src/scrollcase/preview.py create mode 100644 scrollcase/tests/test_preview.py diff --git a/docs/creating-scroll-case.md b/docs/creating-scroll-case.md index 4b96ca1..f6fe233 100644 --- a/docs/creating-scroll-case.md +++ b/docs/creating-scroll-case.md @@ -36,12 +36,15 @@ honeycomb case: left half (with mount disc), right half, and assembly stand. 3. **Generate a draft.** ```bash - uv run scrollcase -v build -c my-scroll.toml -o out/ + uv run scrollcase -v build -c my-scroll.toml -o out/ --preview ``` This writes `-L.stl`, `-R.stl`, `-Stand.stl`, and `-Scroll.stl`. The scroll STL is the aligned scroll in case coordinates; load it with the - halves in a viewer to check placement and clearances. + halves in a viewer to check placement and clearances. `--preview` also + writes and opens `-preview.html`, which shows every part together. + Make the halves see-through or explode them apart to check the fit, and + use **Save snapshot** to save a PNG of the view. 4. **Adjust.** - The scroll is auto-aligned to the case axis, with its widest direction diff --git a/scrollcase/README.md b/scrollcase/README.md index 6a5cd1a..3402c7d 100644 --- a/scrollcase/README.md +++ b/scrollcase/README.md @@ -8,10 +8,13 @@ for every config option. ```bash uv sync -uv run scrollcase build -c examples/generic.toml -o out/ +uv run scrollcase build -c examples/generic.toml -o out/ --preview uv run pytest ``` +`--preview` also writes `-preview.html`, a standalone 3D viewer with +per-part visibility, opacity, and an explode slider (three.js loads from a CDN). + Library use: ```python diff --git a/scrollcase/pyproject.toml b/scrollcase/pyproject.toml index 2521567..c2168da 100644 --- a/scrollcase/pyproject.toml +++ b/scrollcase/pyproject.toml @@ -11,13 +11,14 @@ dependencies = [ "meshlib>=3.1", "numpy", "scipy", + "trimesh", ] [project.scripts] scrollcase = "scrollcase.cli:main" [dependency-groups] -dev = ["pytest", "trimesh"] +dev = ["pytest"] [build-system] requires = ["hatchling"] diff --git a/scrollcase/src/scrollcase/cli.py b/scrollcase/src/scrollcase/cli.py index 042ada1..4984bf1 100644 --- a/scrollcase/src/scrollcase/cli.py +++ b/scrollcase/src/scrollcase/cli.py @@ -1,6 +1,6 @@ """Command line interface. -scrollcase build [MESH] [-c case.toml] [-o OUT_DIR] [--parts left,right,stand] +scrollcase build [MESH] [-c case.toml] [-o OUT_DIR] [--parts left,right,stand] [--preview] scrollcase defaults [--style STYLE] > case.toml """ @@ -8,6 +8,7 @@ import dataclasses import logging import sys +from pathlib import Path from .config import PRESETS, CaseConfig, ConfigError, config_from_dict, load_config @@ -69,6 +70,9 @@ def main(argv=None) -> int: build.add_argument( "--parts", help="comma-separated parts (default: left,right, plus stand if enabled)" ) + build.add_argument( + "--preview", action="store_true", help="write -preview.html and open it in a browser" + ) defaults = sub.add_parser("defaults", help="print a style's full config as TOML") defaults.add_argument("--style", default=CaseConfig.style, choices=sorted(PRESETS)) @@ -120,6 +124,14 @@ def _build(args) -> int: print(f"Height: {L.outer_height:.2f} mm") for path in result.outputs.values(): print(f"Wrote {path}") + if args.preview: + import webbrowser + + from .preview import write_preview + + page = write_preview(result.outputs, Path(args.out) / f"{cfg.name}-preview.html", cfg.name) + print(f"Wrote {page}") + webbrowser.open(page.resolve().as_uri()) return 0 diff --git a/scrollcase/src/scrollcase/preview.html b/scrollcase/src/scrollcase/preview.html new file mode 100644 index 0000000..25e87ad --- /dev/null +++ b/scrollcase/src/scrollcase/preview.html @@ -0,0 +1,235 @@ + + + + + +__TITLE__ + + + + +
Loading…
+ + + + + diff --git a/scrollcase/src/scrollcase/preview.py b/scrollcase/src/scrollcase/preview.py new file mode 100644 index 0000000..d210d2c --- /dev/null +++ b/scrollcase/src/scrollcase/preview.py @@ -0,0 +1,45 @@ +"""Standalone HTML preview of a build's output STLs.""" + +import base64 +import html +import json +from importlib.resources import files +from pathlib import Path + +import trimesh + +# Display order, label, color, and explode direction. The split is at Y=0 +# and the stand cradles the left half from -Y, so it moves out past it. +_PARTS = { + "left": ("Left half", "#4f8fd6", [0, -1, 0]), + "right": ("Right half", "#d6894f", [0, 1, 0]), + "stand": ("Stand", "#8a8f98", [0, -2, 0]), + "scroll": ("Scroll", "#c9b48a", [0, 0, 0]), +} + + +def write_preview(outputs: dict[str, Path], path: str | Path, title: str = "Scroll case") -> Path: + """Write a single HTML file that shows `outputs` (part name -> STL) in 3D. + + The meshes are embedded as one GLB. The page loads three.js from a CDN, + so viewing it needs a network connection but no local server. + """ + scene = trimesh.Scene() + parts = [] + for key in sorted(outputs, key=lambda k: list(_PARTS).index(k) if k in _PARTS else len(_PARTS)): + label, color, explode = _PARTS.get(key, (key, "#999999", [0, 0, 0])) + scene.add_geometry(trimesh.load_mesh(outputs[key]), node_name=key, geom_name=key) + parts.append({"key": key, "label": label, "color": color, "explode": explode}) + + data = { + "title": title, + "parts": parts, + "glb": base64.b64encode(scene.export(file_type="glb")).decode("ascii"), + } + template = files("scrollcase").joinpath("preview.html").read_text() + # " early + payload = json.dumps(data).replace("(.*?)', path.read_text()) + return json.loads(match.group(1)) + + +def test_preview_embeds_every_part(tmp_path): + outputs = {} + for i, key in enumerate(("scroll", "right", "left")): + outputs[key] = tmp_path / f"{key}.stl" + trimesh.creation.box(extents=(10, 10, 10 + i)).export(outputs[key]) + + page = write_preview(outputs, tmp_path / "p.html", title="A") + data = _page_data(page) + + # Listed in display order, each as a named GLB node with its mesh intact + assert [p["key"] for p in data["parts"]] == ["left", "right", "scroll"] + glb = io.BytesIO(base64.b64decode(data["glb"])) + scene = trimesh.load(glb, file_type="glb") + assert set(scene.graph.nodes_geometry) == {"left", "right", "scroll"} + assert scene.geometry["left"].extents[2] == 12 + + assert data["title"] == "A" + assert "A</script><b>" in page.read_text() diff --git a/scrollcase/uv.lock b/scrollcase/uv.lock index 58b0686..c8a96d4 100644 --- a/scrollcase/uv.lock +++ b/scrollcase/uv.lock @@ -1281,12 +1281,12 @@ dependencies = [ { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "trimesh" }, ] [package.dev-dependencies] dev = [ { name = "pytest" }, - { name = "trimesh" }, ] [package.metadata] @@ -1295,13 +1295,11 @@ requires-dist = [ { name = "meshlib", specifier = ">=3.1" }, { name = "numpy" }, { name = "scipy" }, + { name = "trimesh" }, ] [package.metadata.requires-dev] -dev = [ - { name = "pytest" }, - { name = "trimesh" }, -] +dev = [{ name = "pytest" }] [[package]] name = "stack-data" From 7554c58f8716f47bad73736413f9025d5e35e9f2 Mon Sep 17 00:00:00 2001 From: Seth Parker Date: Fri, 2 Oct 2026 12:04:35 -0400 Subject: [PATCH 2/2] Harden preview page: utf-8 I/O, load errors, snapshot download Write the preview as utf-8 regardless of locale, decode the embedded GLB via fetch, show load failures in the status overlay, and defer revoking the snapshot blob URL so the download can start. Co-Authored-By: Claude Opus 5.5 --- scrollcase/src/scrollcase/preview.html | 17 ++++++++++++++--- scrollcase/src/scrollcase/preview.py | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/scrollcase/src/scrollcase/preview.html b/scrollcase/src/scrollcase/preview.html index 25e87ad..84fa30e 100644 --- a/scrollcase/src/scrollcase/preview.html +++ b/scrollcase/src/scrollcase/preview.html @@ -74,6 +74,15 @@

Drag to orbit, right-drag to pan, scroll to zoom. Swatches set color, sliders set opacity.

+