Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/creating-scroll-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>-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 `<name>-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
Expand Down
5 changes: 4 additions & 1 deletion scrollcase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>-preview.html`, a standalone 3D viewer with
per-part visibility, opacity, and an explode slider (three.js loads from a CDN).

Library use:

```python
Expand Down
3 changes: 2 additions & 1 deletion scrollcase/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
14 changes: 13 additions & 1 deletion scrollcase/src/scrollcase/cli.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""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
"""

import argparse
import dataclasses
import logging
import sys
from pathlib import Path

from .config import PRESETS, CaseConfig, ConfigError, config_from_dict, load_config

Expand Down Expand Up @@ -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 <name>-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))
Expand Down Expand Up @@ -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


Expand Down
246 changes: 246 additions & 0 deletions scrollcase/src/scrollcase/preview.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>
:root {
--bg: #eef0f3; --panel: rgba(255, 255, 255, 0.92); --text: #1d2228;
--muted: #66707c; --line: #d5d9df; --grid: #c4c9d0;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1b1e23; --panel: rgba(36, 40, 47, 0.92); --text: #e6e9ed;
--muted: #9aa3ad; --line: #3a404a; --grid: #353b44;
}
}
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--text);
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; }
canvas { display: block; }
#panel { position: absolute; top: 12px; left: 12px; width: 240px; padding: 12px 14px;
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
backdrop-filter: blur(6px); }
h1 { font-size: 14px; margin: 0 0 10px; font-weight: 600; }
.part { display: grid; grid-template-columns: auto 18px 1fr; align-items: center;
gap: 2px 8px; padding: 6px 0; border-top: 1px solid var(--line); }
.part input[type=range] { grid-column: 2 / 4; width: 100%; }
.swatch { width: 18px; height: 18px; padding: 0; border: 1px solid var(--line);
border-radius: 4px; background: none; cursor: pointer; }
.swatch::-webkit-color-swatch-wrapper { padding: 0; }
.swatch::-webkit-color-swatch { border: none; border-radius: 3px; }
.swatch::-moz-color-swatch { border: none; border-radius: 3px; }
label { cursor: pointer; }
.row { display: flex; align-items: center; gap: 8px; padding-top: 8px;
border-top: 1px solid var(--line); }
.row input[type=range] { flex: 1; }
.muted { color: var(--muted); font-size: 12px; }
button { margin-top: 10px; width: 100%; padding: 5px; font: inherit; color: var(--text);
background: transparent; border: 1px solid var(--line); border-radius: 5px; cursor: pointer; }
button:hover { border-color: var(--muted); }
.seg { display: flex; gap: 4px; }
.seg button { margin-top: 0; }
.seg button[aria-pressed=true] { border-color: var(--text); font-weight: 600; }
#status { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
} }
</script>
</head>
<body>
<div id="status">Loading…</div>
<div id="panel" hidden>
<h1 id="title"></h1>
<div id="parts"></div>
<div class="row">
<span>Explode</span>
<input id="explode" type="range" min="0" max="1" step="0.01" value="0">
</div>
<div class="row seg" id="pose">
<button data-pose="upright" aria-pressed="true">Upright</button>
<button data-pose="lying" aria-pressed="false">On stand</button>
</div>
<div class="row">
<input type="checkbox" id="show-grid" checked><label for="show-grid">Ground grid</label>
</div>
<div class="row">
<input type="color" class="swatch" id="background"><label for="background">Background</label>
</div>
<button id="reset">Reset view</button>
<button id="snapshot">Save snapshot</button>
<p class="muted">Drag to orbit, right-drag to pan, scroll to zoom. Swatches set color, sliders set opacity.</p>
</div>
<script id="data" type="application/json">__DATA__</script>
<script>
// Surface load failures (CDN offline, oversized or corrupt model) instead of "Loading…" forever
function showError(e) {
const status = document.getElementById("status");
if (status) status.textContent = `Could not load preview: ${e?.message ?? e}`;
}
addEventListener("error", e => showError(e.error ?? e.message));
addEventListener("unhandledrejection", e => showError(e.reason));
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";

const data = JSON.parse(document.getElementById("data").textContent);
const css = getComputedStyle(document.documentElement);
document.getElementById("title").textContent = data.title;

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
document.body.prepend(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(css.getPropertyValue("--bg").trim());
const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 1, 1e5);
scene.add(camera);
scene.add(new THREE.HemisphereLight(0xffffff, 0x666670, 1.6));
const key = new THREE.DirectionalLight(0xffffff, 1.8);
key.position.set(1, 2, 1);
camera.add(key); // light follows the view so no side goes dark

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// Decoding through fetch is far faster than atob plus a per-character copy
const glb = await (await fetch(`data:model/gltf-binary;base64,${data.glb}`)).arrayBuffer();
const gltf = await new GLTFLoader().parseAsync(glb, "");
const modelSize = new THREE.Box3().setFromObject(gltf.scene).getSize(new THREE.Vector3());
// The world stays Y-up for the orbit controls; poses rotate the model instead
const model = new THREE.Group();
model.add(gltf.scene);
scene.add(model);
// Each pose's default view looks at the right half, away from the stand
const poses = {
upright: { rotation: new THREE.Euler(-Math.PI / 2, 0, 0), view: new THREE.Vector3(0.8, 0.5, -1) },
lying: { rotation: new THREE.Euler(0, 0, 0), view: new THREE.Vector3(1, 0.8, 0.9) },
};
let pose = "upright";

const gridColor = css.getPropertyValue("--grid").trim();
const grid = new THREE.GridHelper(10, 1, gridColor, gridColor);
scene.add(grid);

// Each mesh draws twice: a depth-only pass, then color. When transparent, only
// the front surface blends; otherwise the stacked honeycomb walls make opacity
// look all-or-nothing. Material groups draw in order, so depth always goes first.
const parts = data.parts.map(p => {
const obj = gltf.scene.getObjectByName(p.key);
const material = new THREE.MeshStandardMaterial({
color: p.color, roughness: 0.65, metalness: 0.05, flatShading: true,
});
const depth = new THREE.MeshBasicMaterial({ colorWrite: false, transparent: true, visible: false });
obj.traverse(o => {
if (!o.isMesh) return;
const g = o.geometry;
const count = g.index ? g.index.count : g.attributes.position.count;
g.clearGroups();
g.addGroup(0, count, 0);
g.addGroup(0, count, 1);
o.material = [depth, material];
});
return { ...p, obj, material, depth, home: obj.position.clone(), dir: new THREE.Vector3(...p.explode) };
});

function setOpacity(p, a) {
const transparent = a < 1;
if (transparent !== p.material.transparent) {
p.material.transparent = transparent;
p.material.depthWrite = !transparent;
p.material.needsUpdate = true;
p.depth.visible = transparent;
}
p.material.opacity = a;
}

const list = document.getElementById("parts");
for (const p of parts) {
const row = document.createElement("div");
row.className = "part";
row.innerHTML = `<input type="checkbox" id="show-${p.key}" checked>
<input type="color" class="swatch" value="${p.color}" aria-label="${p.label} color">
<label for="show-${p.key}">${p.label}</label>
<input type="range" min="0.05" max="1" step="0.01" value="1" aria-label="${p.label} opacity">`;
const [check, color, , slider] = row.children;
color.addEventListener("input", () => p.material.color.set(color.value));
check.addEventListener("change", () => { p.obj.visible = check.checked; placeGrid(); });
slider.addEventListener("input", () => setOpacity(p, Number(slider.value)));
list.append(row);
}
if (!parts.some(p => p.key === "stand")) document.querySelector('[data-pose=lying]').textContent = "On side";

function placeGrid() {
const box = new THREE.Box3();
for (const p of parts) if (p.obj.visible) box.expandByObject(p.obj);
if (box.isEmpty()) return;
const size = box.getSize(new THREE.Vector3());
const extent = Math.ceil(Math.max(size.x, size.z) * 1.5 / 10) * 10;
grid.geometry.dispose();
grid.geometry = new THREE.GridHelper(extent, extent / 10, gridColor, gridColor).geometry;
const c = box.getCenter(new THREE.Vector3());
grid.position.set(c.x, box.min.y - 0.5, c.z);
}

document.getElementById("explode").addEventListener("input", e => {
const d = Number(e.target.value) * Math.max(modelSize.x, modelSize.y) * 0.6;
for (const p of parts) p.obj.position.copy(p.home).addScaledVector(p.dir, d);
placeGrid();
});

function resetView() {
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const span = box.getSize(new THREE.Vector3()).length();
controls.target.copy(center);
camera.position.copy(center).addScaledVector(poses[pose].view.clone().normalize(), span * 1.6);
controls.update();
}

function setPose(name) {
pose = name;
model.rotation.copy(poses[name].rotation);
model.updateMatrixWorld(true);
for (const b of document.querySelectorAll("#pose button")) b.setAttribute("aria-pressed", b.dataset.pose === name);
placeGrid();
resetView();
}
for (const b of document.querySelectorAll("#pose button")) b.addEventListener("click", () => setPose(b.dataset.pose));
const background = document.getElementById("background");
background.value = "#" + scene.background.getHexString();
background.addEventListener("input", () => scene.background.set(background.value));
document.getElementById("show-grid").addEventListener("change", e => { grid.visible = e.target.checked; });
document.getElementById("reset").addEventListener("click", resetView);

// The drawing buffer is cleared after each frame, so render right before reading it
document.getElementById("snapshot").addEventListener("click", () => {
renderer.render(scene, camera);
renderer.domElement.toBlob(blob => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${data.title}-snapshot.png`;
a.click();
// Some browsers start the download asynchronously, so revoke later
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});
});
setPose("upright");

addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});

document.getElementById("status").remove();
document.getElementById("panel").hidden = false;
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
</script>
</body>
</html>
45 changes: 45 additions & 0 deletions scrollcase/src/scrollcase/preview.py
Original file line number Diff line number Diff line change
@@ -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(encoding="utf-8")
# "</" would end the inline <script> early
payload = json.dumps(data).replace("</", "<\\/")
page = template.replace("__TITLE__", html.escape(title)).replace("__DATA__", payload)
path = Path(path)
path.write_text(page, encoding="utf-8")
return path
Loading
Loading