diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..49b3525 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,41 @@ +name: Docs + +on: + push: + branches: [main] + paths: [docs/**, scrollcase/examples/**, mkdocs.yml, .github/workflows/docs.yml] + pull_request: + paths: [docs/**, scrollcase/examples/**, mkdocs.yml, .github/workflows/docs.yml] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uvx --with mkdocs-material --with 'mkdocs<2' mkdocs build --strict + - uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 925c559..29a40f1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ cmake-build*/ .venv/ __pycache__/ .pytest_cache/ +site/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6833428 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +3D-printable sample mounts (scroll cases, interface plates, spindles, trays) for scanner/beamline data acquisition. Most of the repo is OpenSCAD source in `OpenSCAD/`, with exported STLs committed to `Models/`. Scroll cases are generated by the Python package in `scrollcase/`, which is the only part with tests. + +## Commands + +Render an OpenSCAD model to STL (parameters are top-level variables, overridable with `-D`): + +```bash +openscad -o out.stl -D rod_type=2 "OpenSCAD/Spindle Base.scad" +``` + +Scroll cases (run from `scrollcase/`; uv manages the environment): + +```bash +uv run scrollcase build path/to/scroll.ply -c case.toml -o out/ +uv run scrollcase defaults # print every config option as TOML +uv run pytest # ~30s; builds real cases +uv run pytest tests/test_lining.py::test_scroll_slides_out_toward_split +``` + +The hanging-frame case is rendered through `OpenSCAD/scripts/HangingCase-Generic.sh`; set `UTILS_DIR` to the absolute path of this clone first. + +Build the C++ utilities (libigl is fetched automatically; `spiral-length` only builds if GSL is found): + +```bash +cmake -S utils -B build && cmake --build build +``` + +C++ formatting follows `.clang-format` (Google-based, 4-space indent). + +## Architecture + +- **Shared base geometry.** `Generic Mount Disc.scad` / `Generic Mount Disc 65mm.scad` define the standard mount base and expose its dimensions as functions (`GenericMountDisc_Diameter()`, `GenericMountDisc_Thickness()`, etc.). Interface plates and spindle bases `use <>` these, so changing a disc dimension affects every dependent model. `scrollcase/src/scrollcase/mount_disc.py` mirrors both discs for the scroll case and must be updated by hand to match. `Generic Mount Ring*` is the mating ring for the disc. +- **`cylinder_outer.scad`** is used almost everywhere instead of `cylinder()`. It scales the radius so the polygon's flats (not vertices) sit at the requested radius, which matters for press fits and clearances. +- **`use <>` semantics.** Files are pulled in with `use`, so only modules/functions are imported. Library files still call their own module at the top level (e.g. `GenericMountDisc();`) so they render standalone. +- **`scrollcase/`.** Python (build123d + meshlib) port of the former OpenSCAD scroll case generator; its mesh pipeline is adapted from ScrollPrize/villa's `scrollcase`. Mesh stage (`lining.py`): align the scroll, offset it to a cavity, split at Y=0, `FixUndercuts` toward the split, offset again for the lining wall. B-rep stage (`case.py`): shell, divider, disc, nubs, escape holes, labels, stand; `scad.py` gives OpenSCAD-style primitives (extrinsic XYZ rotation, `center=` anchoring) so it reads like the original. Halves are assembled as `(body ∪ wall) − cavity`. +- **Never load meshlib and build123d in one process.** meshlib bundles its own OpenCASCADE, and the two segfault or abort together. `pipeline.py` runs `brep_worker` in subprocesses; tests that need build123d use `conftest.run_brep`. +- **OCCT is fragile with coincident or tangent curved faces.** The shell is built by clipping the honeycomb cutter, not by unioning caps or ring bands back over the cylinder; tools overshoot faces; tangent escape holes are nudged. `lining.load_brep_export` fails on any real hole in a B-rep STL, so a new feature that leaves faces coincident shows up as a build error. +- **Variants by copy.** Size/scanner variants (e.g. `SI Interface Plate - 147mm` vs `- 200mm`, `SpindleBase-*`) are separate files or separate `-D` renders, and the resulting STLs in `Models/` are named after the parameter values. When adding a variant, export its STL to `Models/` too. +- **`utils/`.** `SpiralLength.cpp` computes Archimedean spiral length via GSL, for use with `Spiral Frame.scad`. + +## Known gaps + +`Fragment Trays.scad` and `Hanging Frame Case.scad` `use `, which is not in the repo, so they won't render as-is. diff --git a/README.md b/README.md index f2e7df8..123b96f 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,71 @@ # Sample Mounts -A collection of scripts and models used to construct safe sample mounts for -data acquisition. +3D-printable sample mounts (scroll cases, interface plates, spindles, and +trays) for scanner and beamline data acquisition. + +User docs: https://educelab.github.io/sample-mounts/ ## Models -Predesigned models. +Ready-to-print STLs. Variants are named after the parameters they were +rendered with (e.g. `SpindleBase-6.35mm.stl`, `SI Interface Plate - 200mm.stl`). + +- **Mount discs and rings.** The standard base (`Generic Mount Disc`, and a + 65 mm version) and the ring it mates with. +- **Interface plates.** Adapt the mount disc to a specific scanner or + beamline stage (SkyScan 1273, I12.EH1, SI). +- **Spindles.** Spindle bases and stoppers for several rod diameters. ## OpenSCAD -OpenSCAD scripts for generating many of the models. +Parametric sources for the models. Parameters are top-level variables and can +be overridden on the command line: + +```bash +openscad -o out.stl -D rod_type=2 "OpenSCAD/Spindle Base.scad" +``` + +Interface plates and spindle bases are built on `Generic Mount Disc*.scad`, +so changing the disc changes all of them. + +`Fragment Trays.scad` and `Hanging Frame Case.scad` depend on +`Diamond Interface Plate.scad`, which isn't in this repo, so they won't render +as-is. ## scrollcase -Python generator for split scroll cases fit to a scroll mesh. See -[`docs/creating-scroll-case.md`](docs/creating-scroll-case.md). +Python generator for split scroll cases fit to a scroll mesh. Requires +[uv](https://docs.astral.sh/uv/): + +```bash +cd scrollcase +uv run scrollcase defaults --style educelab.v1 > my-scroll.toml +uv run scrollcase build -c my-scroll.toml -o out/ +``` + +See [Creating a scroll case](docs/creating-scroll-case.md) and the +[config reference](docs/scroll-case-config.md). ## utils -Miscellaneous utilities. `SpiralLength.cpp` computes the length of an -Archimedean spiral for use with `Spiral Frame.scad`. + +`SpiralLength.cpp` computes the length of an Archimedean spiral for use with +`Spiral Frame.scad`. It needs [GSL](https://www.gnu.org/software/gsl/): + +```bash +cmake -S utils -B build && cmake --build build +``` + +## Docs + +The user docs in `docs/` are built with MkDocs and published to GitHub Pages +on every push to `main`. To preview locally: + +```bash +uvx --with mkdocs-material --with 'mkdocs<2' mkdocs serve +``` + +## License + +AGPL-3.0. The scroll case's alignment and mesh pipeline are adapted from +[ScrollPrize/villa](https://github.com/ScrollPrize/villa/tree/main/foundation/scrollcase) +(MIT); see `scrollcase/src/scrollcase/LICENSE.villa`. diff --git a/docs/creating-scroll-case.md b/docs/creating-scroll-case.md index bfb9eb2..4b96ca1 100644 --- a/docs/creating-scroll-case.md +++ b/docs/creating-scroll-case.md @@ -1,7 +1,7 @@ # Creating a scroll case Scroll cases are generated by the `scrollcase` Python package in -[`scrollcase/`](../scrollcase). Given a scroll mesh, it aligns the scroll, +[`scrollcase/`](https://github.com/educelab/sample-mounts/tree/main/scrollcase). Given a scroll mesh, it aligns the scroll, builds a lining that follows its surface, and wraps the lining in the split honeycomb case: left half (with mount disc), right half, and assembly stand. @@ -16,15 +16,22 @@ honeycomb case: left half (with mount disc), right half, and assembly stand. be a single watertight surface. Orientation and position don't matter. If it isn't in millimeters, set `scroll.scale`. -2. **Write a config.** Start from the defaults or the example: +2. **Write a config.** Copy + [`scrollcase/examples/template.toml`](https://github.com/educelab/sample-mounts/blob/main/scrollcase/examples/template.toml) + next to your mesh: - ```bash - cd scrollcase - uv run scrollcase defaults > my-scroll.toml + ```toml + --8<-- "template.toml" ``` Set `name`, `scroll.mesh` (relative to the config file), and the label. Every option is described in [the config reference](scroll-case-config.md). + To start from every option instead, print a style's full config: + + ```bash + cd scrollcase + uv run scrollcase defaults --style educelab.v1 > my-scroll.toml + ``` 3. **Generate a draft.** diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ae09549 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,20 @@ +# Sample Mounts + +3D-printable sample mounts (scroll cases, interface plates, spindles, and +trays) for scanner and beamline data acquisition. Source and models are on +[GitHub](https://github.com/educelab/sample-mounts). + +- **Models.** Ready-to-print STLs are in + [`Models/`](https://github.com/educelab/sample-mounts/tree/main/Models). +- **OpenSCAD.** Parametric sources for most models are in + [`OpenSCAD/`](https://github.com/educelab/sample-mounts/tree/main/OpenSCAD). + Parameters are top-level variables and can be overridden with `-D`: + + ```bash + openscad -o out.stl -D rod_type=2 "OpenSCAD/Spindle Base.scad" + ``` + +- **Scroll cases.** Split scroll cases fit to a scroll mesh are generated by + the `scrollcase` Python package. Start with + [Creating a scroll case](creating-scroll-case.md), then see the + [config reference](scroll-case-config.md) for every option. diff --git a/docs/scroll-case-config.md b/docs/scroll-case-config.md index 7db1204..d822d3b 100644 --- a/docs/scroll-case-config.md +++ b/docs/scroll-case-config.md @@ -1,15 +1,54 @@ # Scroll case config reference Scroll cases are configured with a TOML file passed to `scrollcase build -c`. -Every key is optional; anything left out uses the default shown here. Print a -complete config with the defaults filled in: +Every key is optional; anything left out comes from the config's style +preset. Print a style's complete config: ```bash -uv run scrollcase defaults > my-scroll.toml +uv run scrollcase defaults --style educelab.v1 > my-scroll.toml ``` Unknown keys are an error, so typos don't silently fall back to defaults. -Lengths are in millimeters and angles in degrees. +Combinations that can't be built are also rejected when the config loads +(see [Valid combinations](#valid-combinations)). Lengths are in millimeters +and angles in degrees. + +## Styles + +`style` picks a preset of defaults for every key below. Your own values +override the preset key by key, so `[shell] type = "solid"` keeps the rest of +the preset's `[shell]` settings, including its marker rings. + +| Style | Description | +|---|---| +| `educelab.v1` | The default: honeycomb shell with floor and lid, flat split, Generic Mount Disc, stand. The former OpenSCAD generator's design. | +| `villa.2026-10` | [ScrollPrize/villa's case](https://github.com/ScrollPrize/villa/tree/main/foundation/scrollcase) at commit `7d6a82c` (2026-10-01): no shell, curved split ending in round posts, square bolted caps with upstream's label layout, kinematic mount disc, 3 mm margins, no stand. | + +Components mix freely within the [valid combinations](#valid-combinations), +e.g. villa's caps and kinematic disc around a honeycomb shell: + +```toml +style = "educelab.v1" +[split] +type = "curve" +[ends] +type = "caps" +[mount] +type = "kinematic" +[stand] +enabled = false +``` + +`villa.2026-10` reproduces upstream's case bodies exactly; see the +[comparison](https://github.com/educelab/sample-mounts/blob/main/docs/villa-2026-10-comparison.md). The known differences are all in +the mesh stage: the scroll fit uses a tighter tolerance, the wide-axis +rotation normalizes its vector before taking the angle, `voxel_size` is +absolute (upstream uses 0.4% of the mesh diagonal), and `"denoise"` +smoothing uses `smoothing_amount` (upstream ignores its strength setting). + +Released styles never change, so a config rebuilds the same case for as +long as it names the same style. A design update becomes a new version +(e.g. `educelab.v2`). Omitting `style` means `educelab.v1`, permanently. **Case coordinates.** The case axis is +Z and the bottom of the shell is Z=0; the mount disc sits below it, from Z=-12.5 to 0. The halves split on the @@ -20,17 +59,14 @@ half is on the +Y side. | Key | Default | Description | |---|---|---| +| `style` | `"educelab.v1"` | Preset to resolve the rest of the config against. See [Styles](#styles). | | `name` | `"Scroll Case"` | Output file prefix: `-L.stl`, `-R.stl`, `-Stand.stl`, `-Scroll.stl`. Overridden by `--name`. | | `lining_offset` | `2.0` | Clearance between the scroll surface and the inside of the lining. Also the gap above and below the scroll. | | `wall_thickness` | `2.0` | Thickness of the lining wall, shell, lid, and half of the divider. The floor is `max(2, wall_thickness)`. | | `bottom_buffer` | `5.0` | Gap between the floor and the bottom of the lining. | | `top_buffer` | `5.0` | Gap between the top of the lining and the lid. | | `internal_gap` | `3.0` | Radial gap between the lining and the inside of the shell. | -| `outer_cylinder` | `true` | `false` cuts away the upper shell on both sides, leaving an open cradle around the lining. | | `overhang_removal` | `true` | Extrudes each half's cavity toward the split so the scroll can be lowered straight in. Turn off only to inspect the raw lining. | -| `marker_rings` | `true` | Raised rings at the cavity bottom, scroll bottom, scroll middle, scroll top, and cavity top. Only drawn when `honeycomb.enabled` is on. | -| `mount_disc` | `"112.5"` | Mount disc on the left half: `"112.5"` or `"65"` (mm diameter). These match `OpenSCAD/Generic Mount Disc*.scad`. | -| `stand_length_scale` | `1.0` | Stand length as a fraction of the case height. The top cradle moves with it. | | `voxel_size` | `0.4` | Resolution of the lining offsets and overhang removal. Smaller is more faithful but slower and uses more memory; the PHercParis scrolls peaked at 3.5–5 GB at 0.4. | ## `[scroll]` @@ -50,24 +86,79 @@ The input mesh and how it is prepared and placed. | `generic_diameter` | `76.0` | Diameter of the generic cylinder used when `mesh` is unset. | | `generic_height` | `155.0` | Height of the generic cylinder used when `mesh` is unset. | -## `[honeycomb]` +## `[split]` + +The surface the two halves separate along. Every split is a profile +y = f(x) extruded along Z, so from anywhere in a half the scroll can move +straight along Y to the split; overhang removal relies on this. + +| Key | Default | Description | +|---|---|---| +| `type` | `"plane"` | `"plane"`: the XZ plane. `"curve"`: ScrollPrize/villa's S-shaped split, made of two arcs, spanning the whole case out to the middle of the shell wall. A curved seam avoids a flat interface lined up with the beam, which shows up as a streak in CT. | +| `amplitude` | unset | How far the S bulges from the XZ plane. Unset means 0.2 × (span + `wall_thickness`), as upstream, where the span is the radius at the middle of the shell wall. Too large an amplitude is an error. | +| `flip` | `false` | Mirrors the S. | + +With a curved split the divider follows the curve at constant thickness, +and nubs move along Y to sit on it. + +## `[shell]` -Hexagonal cutouts in the shell. The floor, lid, marker ring bands, and -divider stay solid. +The outer cylinder around the lining, including its floor and lid. + +| Key | Default | Description | +|---|---|---| +| `type` | `"honeycomb"` | `"honeycomb"` (hexagonal cutouts), `"solid"`, or `"none"`. With `"none"` the case is just the lining, divider, and ends, as upstream; the divider then ends in round posts at the cavity edge. | +| `open_top` | `false` | Cuts away the upper shell on both sides, leaving an open cradle around the lining. | +| `marker_rings` | `true` | Raised rings at the cavity bottom, scroll bottom, scroll middle, scroll top, and cavity top, on a honeycomb or solid shell. Ignored with `type = "none"`, since there is no shell to put them on, so switching a preset to no shell doesn't also require turning them off. | + +### `[shell.honeycomb]` + +Hexagonal cutouts, used when `shell.type = "honeycomb"`. The floor, lid, +marker ring bands, and divider stay solid. | Key | Default | Description | |---|---|---| -| `enabled` | `true` | `false` gives a solid shell. | | `hole_edges` | `6` | Number of sides on each hole. | | `columns` | `12` | Holes around the circumference. Alternate rows are offset by half a column. | | `spacing` | `1.5` | Width of the strips between holes. | +## `[ends]` + +What closes the top and bottom of the case. + +| Key | Default | Description | +|---|---|---| +| `type` | `"shell"` | `"shell"`: the shell's own floor and lid. `"caps"`: villa's square end caps, sized to the larger of the mount disc and the case (plus one `wall_thickness` on each side around a shell). Bolts through tabs on both sides clamp the halves together; the bottom cap has counterbored mounting holes and an engraved arrow; the top cap carries the label. | +| `cap_height` | `10.0` | Cap thickness. Also the width of the bolt tabs. | +| `corner_fillet` | `6.25` | Radius of the caps' corners. | +| `bolt_hole_diameter` | `5.0` | Clamping bolt clearance hole (M4, loose). | +| `bolt_counterbore_diameter` | `8.0` | Bolt head counterbore, on the +Y face of each tab. | +| `bolt_counterbore_depth` | `2.0` | | +| `nut_diameter` | `9.0` | Hex nut pocket, across corners, on the -Y face of each tab. | +| `nut_depth` | `3.5` | | +| `mount_hole_spacing` | `50.0` | Mounting holes in the bottom cap at (±spacing, ±spacing). The build fails if the counterbores don't fit on the cap, e.g. on a small case with no mount disc. | +| `mount_hole_diameter` | `6.8` | Mounting bolt clearance hole (M6). | +| `mount_counterbore_diameter` | `10.5` | Counterbored from the inside face of the bottom cap. | +| `mount_counterbore_depth` | `5.0` | | + +The `cap_*`, `bolt_*`, `nut_*`, and `mount_*` keys only apply to caps. + +## `[mount]` + +The mount that attaches the left half to the scanner. + +| Key | Default | Description | +|---|---|---| +| `type` | `"generic-112.5"` | `"generic-112.5"` or `"generic-65"`: the Generic Mount Disc of that diameter, matching `OpenSCAD/Generic Mount Disc*.scad`, with an orientation notch on its rim. `"kinematic"`: villa's 112.5 × 10 mm disc with three V-slots for a kinematic mount; the right half's side of its top is lowered 0.5 mm so the right half doesn't bind on it. `"none"`: no mount. | + ## `[nubs]` Alignment nubs on the left half's split face, with matching sockets on the -right half. Place them in the divider wall: farther from the axis than half -the lining's outer diameter, and closer than half the shell's inner diameter. -`scrollcase build` prints both diameters. +right half. Place them on the divider, outside the cavity and inside the +case: the build fails, naming the nub, if one isn't. `scrollcase build` +prints the lining and shell diameters. On a curved split each nub sits at +(x, f(x), z), still pointing along Y, with its base sunk into the divider so +it stays attached where the surface slopes. | Key | Default | Description | |---|---|---| @@ -91,9 +182,12 @@ left half, the floor holes go through the mount disc as well. ## `[label]` -Engraved text on the bottom of the mount disc (left half) and the bottom of -the shell (right half), mirrored so it reads correctly from below. It is also -engraved on the stand's back plate, on the face toward the case. +With shell ends, the label is engraved on the bottom of the mount disc (left +half) and the bottom of the shell (right half), mirrored so it reads +correctly from below. It is also engraved on the stand's back plate, on the +face toward the case. With caps it goes on the top cap in upstream's layout: +`line1` once on each half, `line2`, and a generated ` x ` +line. | Key | Default | Description | |---|---|---| @@ -103,6 +197,27 @@ engraved on the stand's back plate, on the face toward the case. | `depth` | `0.5` | Engraving depth. | | `font` | `"Arial Rounded MT Bold"` | System font name. If the font isn't installed, Arial is used instead. OpenCASCADE's warning about this is hidden when the build succeeds. | +## `[stand]` + +Cradle that holds the left half upright on its mount disc during assembly. + +| Key | Default | Description | +|---|---|---| +| `enabled` | `true` | Builds `-Stand.stl`. `--parts` defaults to `left,right`, plus `stand` when this is on. | +| `length_scale` | `1.0` | Stand length as a fraction of the case height. The top cradle moves with it. | + +## Valid combinations + +These are checked when the config loads, and the error names the keys +involved. + +| Rule | Why | +|---|---| +| `ends.type = "shell"` requires a shell | The floor and lid are part of the shell. | +| `shell.open_top` requires `ends.type = "shell"` | It cuts away the shell's lid. | +| `escape_holes` require a shell | They sit in the gap between the lining and the shell. | +| `stand.enabled` requires a `generic-*` mount and `ends.type = "shell"` | The cradle is shaped around the Generic Mount Disc, its notch, and the round shell. | + ## How the dimensions stack up From the fitted scroll radius `r` and height `h`: @@ -115,7 +230,11 @@ case diameter = shell inner diam. + 2 wall_thickness case height = floor + bottom_buffer + wall_thickness + (h + 2 lining_offset) + wall_thickness - + top_buffer + wall_thickness (lid) + + top_buffer + lid ``` +The floor is `max(2, wall_thickness)` and the lid is `wall_thickness`; with +caps both are `cap_height`. Without a shell, the case diameter is the +lining diameter. + `scrollcase build` prints the scroll, lining, shell, and case sizes. diff --git a/docs/villa-2026-10-comparison.md b/docs/villa-2026-10-comparison.md new file mode 100644 index 0000000..c975cda --- /dev/null +++ b/docs/villa-2026-10-comparison.md @@ -0,0 +1,54 @@ +# `villa.2026-10` vs upstream + +How the `villa.2026-10` style compares with +[ScrollPrize/villa's scrollcase](https://github.com/ScrollPrize/villa/tree/main/foundation/scrollcase) +at commit `7d6a82c` (2026-10-01). Upstream was run in its own environment +with its pinned dependencies (build123d `30bbe96`, meshlib 3.0.5.215). +Comparisons done 2026-10-01. + +## Case bodies: exact match + +Upstream's `build_case` and our `left_body`/`right_body` were built for +four scroll sizes (radius × height in mm): 38 × 155, 20 × 60, +44.41 × 167.58, and 32.83 × 178.06. Upstream puts Z=0 at the scroll bottom, +so our bodies were shifted down by `scroll_z`. + +For all eight bodies (left and right at each size), the volumes and +bounding boxes match, and the symmetric difference between ours and +upstream's is 0.000 mm³. This includes the caps, bolt tabs, nut pockets, +mounting holes, arrow, top-cap text, curved divider with end posts, and +kinematic disc with its 0.5 mm clearance. + +`tests/test_villa_reference.py` keeps it that way, using reference volumes +and bounding boxes generated by `scrollcase/scripts/villa_reference.py`. + +## End to end: PHercParis3 and PHercParis4 + +Both pipelines were run on `phercparis3-scaled.ply` and +`phercparis4-scaled.ply`: upstream's `build_lining`, `build_case`, and +`combine_brep_case_lining`, and our `scrollcase build` with +`style = "villa.2026-10"`. + +| | Fitted radius | Fitted height | Holes (L + R) | Separate bodies (L / R) | Min. scroll clearance | Blocked slide-out paths | +|---|---|---|---|---|---|---| +| PHercParis3, upstream | 44.60 | 166.55 | 26 | 41 / 13 | 1.80 mm | 0 of 300 | +| PHercParis3, ours | 44.41 | 167.58 | 0 | 1 / 1 | 1.95 mm | 0 of 300 | +| PHercParis4, upstream | 32.93 | 178.05 | 2 | 1 / 1 | 1.82 mm | 0 of 300 | +| PHercParis4, ours | 32.83 | 178.06 | 0 | 1 / 1 | 1.94 mm | 0 of 300 | + +- **Fit.** Our tighter fit tolerance gives a 0.1–0.2 mm smaller radius. + Heights agree within 1 mm. +- **Mesh quality.** Upstream's PHercParis3 halves have open holes and many + separate fragments; ours are closed single bodies. +- **Clearance.** The target is `lining_offset` = 2 mm. Upstream's coarser + voxels (0.4% of the mesh diagonal, about 0.8 mm here) leave 1.8 mm; ours + (0.4 mm) leave 1.95 mm. +- **Placement.** Our scrolls sit 180° about the case axis from upstream's. + Both pipelines pick the wide axis's sign arbitrarily, and because the S + is point-symmetric, a 180° turn swaps which part of the scroll goes in + which half. Turned 180°, upstream's PHercParis4 scroll is within 0.45 mm + (mean) of ours. PHercParis3 is also turned 6° by upstream's wide-axis + calculation, which doesn't normalize its vector before `arccos`; turned + 180°, it's within 1.2 mm. Total case volume agrees within 0.1%; only the + left/right split differs. For these two scrolls, + `scroll.rotate = [0, 0, 180]` reproduces upstream's orientation. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..0f65453 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,44 @@ +site_name: Sample Mounts +site_url: https://educelab.github.io/sample-mounts/ +repo_url: https://github.com/educelab/sample-mounts +edit_uri: edit/main/docs/ + +theme: + name: material + features: + - content.code.copy + - navigation.sections + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + +markdown_extensions: + - admonition + - attr_list + - pymdownx.highlight + - pymdownx.snippets: + base_path: [scrollcase/examples] + check_paths: true + - pymdownx.superfences + - toc: + permalink: true + +watch: + - scrollcase/examples + +exclude_docs: | + villa-2026-10-comparison.md + +nav: + - Home: index.md + - Scroll cases: + - Creating a scroll case: creating-scroll-case.md + - Config reference: scroll-case-config.md diff --git a/scrollcase/README.md b/scrollcase/README.md index e43b6fb..6a5cd1a 100644 --- a/scrollcase/README.md +++ b/scrollcase/README.md @@ -42,6 +42,11 @@ B-rep stage in `brep_worker` subprocesses and exchanges STLs. Don't import `mount_disc.py` mirrors `OpenSCAD/Generic Mount Disc*.scad`, which the interface plates and spindle bases still use. Keep them in sync. +`villa.2026-10` is checked against upstream's own code: run +`scripts/villa_reference.py` in an environment with villa's pinned +dependencies (see its docstring) to regenerate the reference data in +`tests/data/`. See [`docs/villa-2026-10-comparison.md`](../docs/villa-2026-10-comparison.md). + ## Attribution The alignment approach and mesh pipeline are adapted from the diff --git a/scrollcase/examples/generic.toml b/scrollcase/examples/generic.toml index 51afa35..4bcd447 100644 --- a/scrollcase/examples/generic.toml +++ b/scrollcase/examples/generic.toml @@ -1,16 +1,19 @@ # Equivalent of the old GenCase-Generic.sh defaults. # Run: scrollcase build -c examples/generic.toml -o out/ +style = "educelab.v1" name = "Generic Scroll Case-v3" bottom_buffer = 10 top_buffer = 3 internal_gap = 4 -outer_cylinder = false [scroll] # mesh = "PHerc-XXXX.ply" # relative to this file; omit for a generic cylinder generic_diameter = 76 generic_height = 155 +[shell] +open_top = true + [nubs] positions = [] size = 3 diff --git a/scrollcase/examples/template.toml b/scrollcase/examples/template.toml new file mode 100644 index 0000000..b20b375 --- /dev/null +++ b/scrollcase/examples/template.toml @@ -0,0 +1,26 @@ +# Starting point for a scroll case. Copy it next to your mesh and edit. +# Anything left out comes from the style preset; every option is listed in +# the config reference, or run: scrollcase defaults --style educelab.v1 +style = "educelab.v1" +name = "PHerc-XXXX" # output files are -L.stl, -R.stl, ... + +[scroll] +mesh = "PHerc-XXXX.ply" # relative to this file +scale = 1.0 # set if the mesh isn't in millimeters +rotate = [0, 0, 0] # degrees about X, Y, Z, applied after alignment +smoothing = "none" # "denoise" or "shrink_expand" for very detailed meshes + +[split] +type = "plane" # "curve" avoids a flat seam lined up with the beam + +[nubs] +# [x, z] pairs on the divider, between the lining and the shell. The build +# prints both diameters; place nubs after a first draft. +positions = [] + +[escape_holes] +enabled = false + +[label] +line1 = "PHerc-XXXX" +line2 = "" diff --git a/scrollcase/scripts/villa_reference.py b/scrollcase/scripts/villa_reference.py new file mode 100644 index 0000000..881c6ce --- /dev/null +++ b/scrollcase/scripts/villa_reference.py @@ -0,0 +1,66 @@ +"""Build ScrollPrize/villa's case bodies as reference data for `villa.2026-10`. + +Run with villa's own pinned dependencies, not this package's environment: + + uv venv --python 3.12 villa-env + uv pip install --python villa-env/bin/python numpy scipy \\ + "build123d @ git+https://github.com/gumyr/build123d@30bbe96cfe0410e3c9cd3f1418ba9e227a546bce" + villa-env/bin/python scripts/villa_reference.py VILLA_CHECKOUT OUT_DIR + +VILLA_CHECKOUT is a clone of ScrollPrize/villa at 7d6a82c. Writes +`reference.json` (volumes and bounding boxes, used by the test suite) and +STEP files of each body for exact comparisons. +""" + +import importlib +import json +import sys +import types +from pathlib import Path + +from build123d import export_step + +# Scroll (radius, height) pairs to build. Copy reference.json to +# tests/data/villa-2026-10-reference.json after regenerating. +SIZES = [(38.0, 155.0), (20.0, 60.0), (44.41, 167.58), (32.83, 178.06)] +LABEL = ("PHERC", "v3") + + +def load_villa_case(checkout: Path): + """Import villa's case.py without running its package __init__, which imports meshlib.""" + package = types.ModuleType("villa_case") + package.__path__ = [str(checkout / "foundation/scrollcase/src/scrollcase")] + sys.modules["villa_case"] = package + return importlib.import_module("villa_case.case") + + +def main(checkout: str, out_dir: str) -> None: + case = load_villa_case(Path(checkout)) + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + + reference = [] + for radius, height in SIZES: + cfg = case.ScrollCaseConfig( + scroll_height_mm=height, + scroll_radius_mm=radius, + label_line_1=LABEL[0], + label_line_2=LABEL[1], + ) + left, right = case.build_case(cfg) + entry = {"radius": radius, "height": height} + for side, solid in (("left", left), ("right", right)): + b = solid.bounding_box() + entry[side] = { + "volume": solid.volume, + "bbox": [b.min.X, b.min.Y, b.min.Z, b.max.X, b.max.Y, b.max.Z], + } + export_step(solid, str(out / f"villa-r{radius}-h{height}-{side}.step")) + reference.append(entry) + volumes = ", ".join(f"{side} {entry[side]['volume']:.1f}" for side in ("left", "right")) + print(f"r={radius} h={height}: {volumes}") + (out / "reference.json").write_text(json.dumps(reference, indent=1)) + + +if __name__ == "__main__": + main(*sys.argv[1:3]) diff --git a/scrollcase/src/scrollcase/alignment.py b/scrollcase/src/scrollcase/alignment.py index 25201cd..0ca0cac 100644 --- a/scrollcase/src/scrollcase/alignment.py +++ b/scrollcase/src/scrollcase/alignment.py @@ -31,8 +31,8 @@ def fit_cylinder( The axis is found by minimizing the radius of the smallest circle enclosing the points projected onto the axis-normal plane, starting from the first - principal component. A loose `tol` stops ~1 degree off-axis, which - oversizes the radius by about a millimeter on a typical scroll. + principal component. A loose `tol` stops short of the minimum: on a test + cylinder it oversized the radius by about 1 mm, on real scrolls 0.1-0.2 mm. Returns: A rotation matrix and translation that map points into cylinder diff --git a/scrollcase/src/scrollcase/caps.py b/scrollcase/src/scrollcase/caps.py new file mode 100644 index 0000000..dd7ba1e --- /dev/null +++ b/scrollcase/src/scrollcase/caps.py @@ -0,0 +1,125 @@ +"""Square bolted end caps, ported from ScrollPrize/villa's `case.py`. + +Kept close to upstream's builder-mode code so the two are easy to compare. +Each cap is built with its bottom face at Z=0. +""" + +from build123d import ( + Align, + Arrow, + Axis, + BuildPart, + BuildSketch, + Cylinder, + Line, + Location, + Locations, + Mode, + Part, + Rectangle, + RegularPolygon, + Text, + add, + extrude, + fillet, +) + +from .config import CaseConfig, Layout + + +def _hex_nut(diameter: float, depth: float) -> Part: + with BuildPart() as hex_part: + with BuildSketch(): + RegularPolygon(radius=diameter / 2, side_count=6) + extrude(amount=depth) + return hex_part.part + + +def cap(cfg: CaseConfig, L: Layout) -> Part: + """Square cap with bolt tabs on both sides of the split.""" + e = cfg.ends + half, h = L.cap_half_width, e.cap_height + tab_x = half + h / 2 + # Built first: a BuildPart nested in another modifies the outer one + nut = _hex_nut(e.nut_diameter, e.nut_depth + 10) + with BuildPart() as cap_part: + with BuildSketch(): + r = Rectangle(2 * half, 2 * half) + fillet(r.vertices(), e.corner_fillet) + with Locations((-half, 0)): + r2 = Rectangle(h, h * 2, align=(Align.MAX, Align.CENTER)) + fillet(r2.vertices(), h / 2) + with Locations((half, 0)): + r3 = Rectangle(h, h * 2, align=(Align.MIN, Align.CENTER)) + fillet(r3.vertices(), h / 2) + extrude(amount=h) + + # Bolt holes along Y through each tab + with Locations((-tab_x, 0, h / 2), (tab_x, 0, h / 2)): + Cylinder(e.bolt_hole_diameter / 2, 4 * h, rotation=(90, 0, 0), mode=Mode.SUBTRACT) + # Bolt head counterbores on the +Y face + with Locations( + (-tab_x, h - e.bolt_counterbore_depth, h / 2), + (tab_x, h - e.bolt_counterbore_depth, h / 2), + ): + Cylinder( + e.bolt_counterbore_diameter / 2, + 2 * h, + mode=Mode.SUBTRACT, + rotation=(90, 0, 0), + align=(Align.CENTER, Align.CENTER, Align.MAX), + ) + # Nut pockets on the -Y face + with Locations((-tab_x, -h + e.nut_depth, h / 2), (tab_x, -h + e.nut_depth, h / 2)): + add(nut, mode=Mode.SUBTRACT, rotation=(90, 0, 0)) + return cap_part.part + + +def top_cap(cfg: CaseConfig, L: Layout) -> Part: + """Top cap engraved with upstream's label layout and the lining size.""" + lb = cfg.label + half = L.cap_half_width + size = f"{L.lining_diameter:.2f}D x {L.cavity_height:.2f}H" + lines = [ + ((0, 40), lb.line1), + ((0, 40 - half), lb.line1), + ((0, 30 - half), lb.line2), + ((0, 20 - half), size), + ] + with BuildPart() as top_cap_part: + add(cap(cfg, L)) + top_face = top_cap_part.faces().sort_by(Axis.Z)[-1] + with BuildSketch(top_face): + for pos, text in lines: + if text: + with Locations(pos): + Text(text, lb.height, font=lb.font) + extrude(amount=-lb.depth, mode=Mode.SUBTRACT) + return top_cap_part.part + + +def bottom_cap(cfg: CaseConfig, L: Layout) -> Part: + """Bottom cap with counterbored mounting holes and an alignment arrow.""" + e = cfg.ends + half, h, s = L.cap_half_width, e.cap_height, e.mount_hole_spacing + # Created outside the builder, which only accepts lines in a BuildLine + shaft = Line((0, half), (0, half - h)) + with BuildPart() as bottom_cap_part: + add(cap(cfg, L)) + with Locations((-s, -s, h), (s, -s, h), (-s, s, h), (s, s, h)): + Cylinder( + e.mount_hole_diameter / 2, + h, + mode=Mode.SUBTRACT, + align=(Align.CENTER, Align.CENTER, Align.MAX), + ) + Cylinder( + e.mount_counterbore_diameter / 2, + e.mount_counterbore_depth, + mode=Mode.SUBTRACT, + align=(Align.CENTER, Align.CENTER, Align.MAX), + ) + with BuildSketch(Location((0, 0, h))): + Arrow(h / 2, shaft, h / 8) + extrude(amount=-cfg.label.depth, mode=Mode.SUBTRACT) + return bottom_cap_part.part diff --git a/scrollcase/src/scrollcase/case.py b/scrollcase/src/scrollcase/case.py index 84cea83..a422841 100644 --- a/scrollcase/src/scrollcase/case.py +++ b/scrollcase/src/scrollcase/case.py @@ -8,18 +8,27 @@ from build123d import ( Align, + Kind, + Line, Part, Plane, Polygon, + Side, Text, + ThreePointArc, Torus, + Wire, extrude, + make_face, mirror, + offset, ) -from .config import CaseConfig, Layout -from .mount_disc import MOUNT_DISCS, MountDisc +from .caps import bottom_cap, top_cap +from .config import NUB_ROTATIONS, CaseConfig, Layout, nub_extent +from .mount_disc import MOUNT_DISCS, KinematicDisc from .scad import cube, cylinder, rotate, scaled, sphere, translate +from .split import Profile, profile_for _BIG = 5000.0 # Added depth so engraved features don't leave coincident faces @@ -27,13 +36,41 @@ # Escape holes this close to tangent with a shell surface get nudged inward _TANGENT_TOL = 0.05 _MARKER_BAND_HEIGHT = 3 -# Nubs alternate between square and diamond orientations -_NUB_ROTATIONS = (0, 45) -def _half_space(side: str) -> Part: - y = -_BIG if side == "left" else 0 - return translate([-_BIG / 2, y, -_BIG / 2], cube([_BIG, _BIG, _BIG])) +def _profile_edges(profile: Profile, extent: float) -> list: + """Edges along the split profile from x = -extent to +extent.""" + if not profile.arcs: + return [Line((-extent, 0), (extent, 0))] + edges = [ThreePointArc(a.start, a.mid, a.end) for a in profile.arcs] + if extent > profile.span: + edges = [ + Line((-extent, 0), (-profile.span, 0)), + *edges, + Line((profile.span, 0), (extent, 0)), + ] + return edges + + +def _half_space(profile: Profile, side: str) -> Part: + if not profile.arcs: + y = -_BIG if side == "left" else 0 + return translate([-_BIG / 2, y, -_BIG / 2], cube([_BIG, _BIG, _BIG])) + y = -_BIG if side == "left" else _BIG + edges = _profile_edges(profile, _BIG) + [ + Line((_BIG, 0), (_BIG, y)), + Line((_BIG, y), (-_BIG, y)), + Line((-_BIG, y), (-_BIG, 0)), + ] + return translate([0, 0, -_BIG / 2], extrude(make_face(edges), amount=_BIG)) + + +def _band(profile: Profile, half_width: float, extent: float, h: float) -> Part: + """Wall of constant thickness along the profile, with round ends.""" + outline = offset( + Wire(_profile_edges(profile, extent)), amount=half_width, side=Side.BOTH, kind=Kind.ARC + ) + return extrude(make_face(outline), amount=h) def _prism(h: float, d: float, edges: int) -> Part: @@ -47,7 +84,7 @@ def _prism(h: float, d: float, edges: int) -> Part: def _honeycomb_holes(cfg: CaseConfig, L: Layout) -> list[Part]: - hc = cfg.honeycomb + hc = cfg.shell.honeycomb d = L.outer_diameter gap_rot = math.degrees(hc.spacing / d) hex_rot = (360 - gap_rot * hc.columns) / hc.columns @@ -87,31 +124,48 @@ def _honeycomb_cutter(cfg: CaseConfig, L: Layout) -> Part: shell's cylindrical faces give OpenCASCADE trouble. """ d = L.outer_diameter - mid_h = L.outer_height - L.wall - L.bottom_wall + mid_h = L.outer_height - L.lid_thickness - L.bottom_wall keep = translate([0, 0, L.bottom_wall], cylinder(mid_h, d)) - if cfg.marker_rings: + if cfg.shell.marker_rings: for z in marker_ring_heights(L): keep -= translate([0, 0, z], cylinder(_MARKER_BAND_HEIGHT, d, center=True)) - keep -= translate( - [0, 0, L.outer_height / 2], cube([2 * d, 2 * L.wall, 2 * L.outer_height], center=True) - ) + profile = profile_for(cfg, L) + if profile.arcs: + keep -= translate( + [0, 0, -L.outer_height / 2], _band(profile, L.wall, d, 2 * L.outer_height) + ) + else: + keep -= translate( + [0, 0, L.outer_height / 2], cube([2 * d, 2 * L.wall, 2 * L.outer_height], center=True) + ) holes = _honeycomb_holes(cfg, L) return holes[0].fuse(*holes[1:]) & keep -def shell(cfg: CaseConfig, L: Layout) -> Part: - """Outer cylinder with floor and lid, optionally honeycombed.""" - outer = cylinder(L.outer_height, L.outer_diameter / 2) - hollow = translate([0, 0, L.inner_z], cylinder(L.inner_height, L.inner_diameter / 2)) - body = outer - hollow +def shell(cfg: CaseConfig, L: Layout) -> Part | None: + """Outer cylinder, optionally honeycombed. - if cfg.honeycomb.enabled: + With shell ends it includes the floor and lid. With caps it's an open + tube whose ends are buried halfway into the caps, so no faces coincide. + """ + if cfg.shell.type == "none": + return None + if cfg.ends.type == "shell": + outer = cylinder(L.outer_height, L.outer_diameter / 2) + hollow = translate([0, 0, L.inner_z], cylinder(L.inner_height, L.inner_diameter / 2)) + body = outer - hollow + else: + z0, z1 = L.bottom_wall / 2, L.outer_height - L.lid_thickness / 2 + tube = cylinder(z1 - z0, L.outer_diameter / 2) - cylinder(z1 - z0, L.inner_diameter / 2) + body = translate([0, 0, z0], tube) + + if cfg.shell.type == "honeycomb": body -= _honeycomb_cutter(cfg, L) - if cfg.marker_rings: - for z in marker_ring_heights(L): - body += translate([0, 0, z], Torus(L.outer_diameter / 2, 0.5)) + if cfg.shell.marker_rings: + for z in marker_ring_heights(L): + body += translate([0, 0, z], Torus(L.outer_diameter / 2, 0.5)) - if not cfg.outer_cylinder: + if cfg.shell.open_top: # Scoop away the upper shell, leaving an open cradle around the lining tilt = 5 size = [L.outer_diameter * 2, L.outer_diameter * 1.5, L.outer_height * 2] @@ -121,11 +175,43 @@ def shell(cfg: CaseConfig, L: Layout) -> Part: return body -def divider(L: Layout) -> Part: - """Wall across the split plane. It ends mid-shell so no faces coincide.""" - w, t, h = L.outer_diameter, 2 * L.wall, L.outer_height - slab = translate([-w / 2, -t / 2, 0], cube([w, t, h])) - return slab & cylinder(h, (L.inner_diameter + L.outer_diameter) / 4) +def divider(cfg: CaseConfig, L: Layout) -> Part: + """Wall along the split, holding the lining. + + With a shell it ends mid-shell so no faces coincide. Without one it ends + at the cavity edge in round posts, as upstream. With caps it's buried + halfway into each cap. + """ + profile = profile_for(cfg, L) + if cfg.ends.type == "shell": + z0, z1 = 0.0, L.outer_height + else: + z0, z1 = L.bottom_wall / 2, L.outer_height - L.lid_thickness / 2 + h = z1 - z0 + if cfg.shell.type == "none": + return translate([0, 0, z0], _band(profile, L.wall, profile.span, h)) + mid = (L.inner_diameter + L.outer_diameter) / 4 + if profile.arcs: + wall = _band(profile, L.wall, profile.span, h) & cylinder(h, mid) + else: + w, t = L.outer_diameter, 2 * L.wall + wall = translate([-w / 2, -t / 2, 0], cube([w, t, h])) & cylinder(h, mid) + return translate([0, 0, z0], wall) if z0 else wall + + +def _case_body(cfg: CaseConfig, L: Layout) -> Part: + """Everything that gets split: shell, divider, and caps.""" + body = divider(cfg, L) + if (sh := shell(cfg, L)) is not None: + body = sh + body + if cfg.ends.type == "caps": + body += bottom_cap(cfg, L) + body += translate([0, 0, L.outer_height - L.lid_thickness], top_cap(cfg, L)) + return body + + +def _mount_thickness(cfg: CaseConfig) -> float: + return MOUNT_DISCS[cfg.mount.type].thickness if cfg.mount.type != "none" else 0.0 def label(cfg: CaseConfig) -> Part | None: @@ -155,7 +241,7 @@ def _escape_xy(cfg: CaseConfig, L: Layout) -> tuple[float, float]: return math.cos(math.radians(eh.angle)) * dist, math.sin(math.radians(eh.angle)) * dist -def _escape_holes(cfg: CaseConfig, L: Layout, disc: MountDisc, side: str) -> list[Part]: +def _escape_holes(cfg: CaseConfig, L: Layout, side: str) -> list[Part]: ex, ey = _escape_xy(cfg, L) r = cfg.escape_holes.diameter / 2 # Overshoot both faces so no cut ends flush with an existing face. Inside, @@ -163,55 +249,93 @@ def _escape_holes(cfg: CaseConfig, L: Layout, disc: MountDisc, side: str) -> lis over, inner_over = 1.0, 0.2 if side == "left": ey = -ey - bottom_z = -disc.thickness - over + bottom_z = -_mount_thickness(cfg) - over else: bottom_z = -over bottom = translate([0, 0, bottom_z], cylinder(L.inner_z + inner_over - bottom_z, r)) - top_z = L.outer_height - L.wall - inner_over + top_z = L.outer_height - L.lid_thickness - inner_over top = translate([0, 0, top_z], cylinder(L.outer_height + over - top_z, r)) return [translate([sx * ex, ey, 0], h) for sx in (-1, 1) for h in (bottom, top)] -def _nub_box(size: float, depth: float) -> Part: - return translate([0, depth / 2, 0], cube([size, depth, size], center=True)) +def _nub_box(size: float, depth: float, sink: float = 0.0) -> Part: + """Box standing `depth` proud of the split, extending `sink` below it.""" + return translate([0, (depth - sink) / 2, 0], cube([size, depth + sink, size], center=True)) + + +def _nub_anchors(cfg: CaseConfig, L: Layout): + """Each nub's position on the split surface and how far to sink its base. + + On a sloped split the base sinks far enough to stay attached across its + width, but no deeper than the divider is thick. + """ + profile = profile_for(cfg, L) + for i, (x, z) in enumerate(cfg.nubs.positions): + xn = -x + sink = min(abs(profile.slope(xn)) * nub_extent(cfg, i), L.wall) + yield i, (xn, float(profile.f(xn)), z), sink + + +def _mount(cfg: CaseConfig, L: Layout, profile: Profile) -> Part | None: + if cfg.mount.type == "none": + return None + disc = MOUNT_DISCS[cfg.mount.type] + solid = disc.solid() + if isinstance(disc, KinematicDisc): + top = translate([-_BIG / 2, -_BIG / 2, -disc.right_clearance], cube([_BIG, _BIG, _BIG])) + solid -= _half_space(profile, "right") & top + else: + # Orientation notch on the rim + solid -= translate([-1, -disc.diameter / 2 - 1, -disc.thickness - 1], cube([2, 2, 2])) + return solid def left_body(cfg: CaseConfig, L: Layout) -> Part: - disc = MOUNT_DISCS[cfg.mount_disc] - body = (shell(cfg, L) + divider(L)) & _half_space("left") - body += disc.solid() + profile = profile_for(cfg, L) + body = _case_body(cfg, L) & _half_space(profile, "left") + if (mount := _mount(cfg, L, profile)) is not None: + body += mount n = cfg.nubs - for i, (x, z) in enumerate(n.positions): - nub = rotate([0, _NUB_ROTATIONS[i % 2], 0], _nub_box(n.size, n.depth)) - body += translate([-x, 0, z], nub) + for i, pos, sink in _nub_anchors(cfg, L): + nub = rotate([0, NUB_ROTATIONS[i % 2], 0], _nub_box(n.size, n.depth, sink)) + body += translate(pos, nub) if cfg.escape_holes.enabled: - body = body.cut(*_escape_holes(cfg, L, disc, "left")) - if (tool := label(cfg)) is not None: - body -= translate([0, disc.diameter / 4, -disc.thickness - _LABEL_EXTRA], tool) - # Orientation notch on the disc rim - body -= translate([-1, -disc.diameter / 2 - 1, -disc.thickness - 1], cube([2, 2, 2])) + body = body.cut(*_escape_holes(cfg, L, "left")) + # Caps carry the label on the top cap instead + if cfg.ends.type == "shell" and (tool := label(cfg)) is not None: + y = ( + MOUNT_DISCS[cfg.mount.type].diameter / 4 + if cfg.mount.type != "none" + # Without a disc the left half only spans -Y + else -L.outer_diameter / 4 + ) + body -= translate([0, y, -_mount_thickness(cfg) - _LABEL_EXTRA], tool) return body def right_body(cfg: CaseConfig, L: Layout) -> Part: - disc = MOUNT_DISCS[cfg.mount_disc] - body = (shell(cfg, L) + divider(L)) & _half_space("right") + profile = profile_for(cfg, L) + body = _case_body(cfg, L) & _half_space(profile, "right") n = cfg.nubs socket = n.size + 2 * n.margin socket_depth = n.depth + 2 * n.margin - for x, z in n.positions: - housing = _nub_box(socket + L.wall, n.depth + L.wall) - body += translate([-x, 0, z], housing) - for i, (x, z) in enumerate(n.positions): - hollow = scaled([1.01] * 3, _nub_box(socket, socket_depth)) - body -= translate([-x, 0, z], rotate([0, _NUB_ROTATIONS[i % 2], 0], hollow)) + anchors = list(_nub_anchors(cfg, L)) + for _, pos, sink in anchors: + housing = translate(pos, _nub_box(socket + L.wall, n.depth + L.wall, sink)) + if sink > 0: + # Keep the housing from crossing the split into the left half + housing &= _half_space(profile, "right") + body += housing + for i, pos, sink in anchors: + hollow = scaled([1.01] * 3, _nub_box(socket, socket_depth, sink)) + body -= translate(pos, rotate([0, NUB_ROTATIONS[i % 2], 0], hollow)) if cfg.escape_holes.enabled: - body = body.cut(*_escape_holes(cfg, L, disc, "right")) - if (tool := label(cfg)) is not None: + body = body.cut(*_escape_holes(cfg, L, "right")) + if cfg.ends.type == "shell" and (tool := label(cfg)) is not None: body -= translate([0, L.outer_diameter / 4, -_LABEL_EXTRA], tool) return body @@ -233,8 +357,8 @@ def _stand_wall(width: float, height: float, thickness: float, support: float) - def stand(cfg: CaseConfig, L: Layout) -> Part: """Cradle that holds the left half upright on its disc during assembly.""" - disc = MOUNT_DISCS[cfg.mount_disc] - base_len = disc.thickness + L.outer_height * cfg.stand_length_scale + disc = MOUNT_DISCS[cfg.mount.type] + base_len = disc.thickness + L.outer_height * cfg.stand.length_scale base_w = max(disc.diameter, L.outer_diameter) + 0.5 base_t = 5 strip = 30 @@ -255,7 +379,7 @@ def stand(cfg: CaseConfig, L: Layout) -> Part: top_h = base_w / 2 - L.outer_diameter / 4 top = rotate([0, 180, 0], _stand_wall(L.outer_diameter + 0.5, top_h, disc.thickness, support)) top = translate( - [0, -base_w / 2 + top_h / 2 - 0.5, cfg.stand_length_scale * L.outer_height - 1.25 * base_t], + [0, -base_w / 2 + top_h / 2 - 0.5, cfg.stand.length_scale * L.outer_height - 1.25 * base_t], top, ) bottom_h = base_w / 2 - disc.diameter / 4 + 0.5 diff --git a/scrollcase/src/scrollcase/cli.py b/scrollcase/src/scrollcase/cli.py index 7c4ae06..042ada1 100644 --- a/scrollcase/src/scrollcase/cli.py +++ b/scrollcase/src/scrollcase/cli.py @@ -1,7 +1,7 @@ """Command line interface. scrollcase build [MESH] [-c case.toml] [-o OUT_DIR] [--parts left,right,stand] -scrollcase defaults > case.toml +scrollcase defaults [--style STYLE] > case.toml """ import argparse @@ -9,7 +9,7 @@ import logging import sys -from .config import CaseConfig, load_config +from .config import PRESETS, CaseConfig, ConfigError, config_from_dict, load_config def _toml_value(value) -> str: @@ -22,28 +22,43 @@ def _toml_value(value) -> str: return repr(value) -def defaults_toml() -> str: - """Default config as TOML. `scroll.mesh` is left commented out.""" - data = dataclasses.asdict(CaseConfig()) - lines, tables = [], [] - for key, value in data.items(): - if isinstance(value, dict): - tables.append((key, value)) - else: - lines.append(f"{key} = {_toml_value(value)}") - for name, table in tables: - lines.append(f"\n[{name}]") - for key, value in table.items(): - if value is None: - lines.append(f'# {key} = "path/to/mesh.ply"') +# Example values shown for keys that are unset by default +_UNSET_HINTS = { + "mesh": '"path/to/mesh.ply"', + "amplitude": "10.0 # default: 0.2 x (span + wall_thickness)", +} + + +def config_toml(cfg: CaseConfig) -> str: + """A fully resolved config as TOML. Unset optional values are commented out.""" + lines: list[str] = [] + + def table(data: dict, prefix: str) -> None: + nested = [] + for key, value in data.items(): + if isinstance(value, dict): + nested.append((key, value)) + elif value is None: + lines.append(f"# {key} = {_UNSET_HINTS.get(key, '...')}") else: lines.append(f"{key} = {_toml_value(value)}") + for key, value in nested: + lines.append(f"\n[{prefix}{key}]") + table(value, f"{prefix}{key}.") + + table(dataclasses.asdict(cfg), "") return "\n".join(lines) + "\n" +def defaults_toml(style: str = CaseConfig.style) -> str: + return config_toml(config_from_dict({"style": style})) + + def main(argv=None) -> int: parser = argparse.ArgumentParser(prog="scrollcase", description=__doc__.split("\n")[0]) - parser.add_argument("-v", "--verbose", action="store_true") + parser.add_argument( + "-v", "--verbose", action="store_true", help="log progress and show full tracebacks" + ) sub = parser.add_subparsers(dest="command", required=True) build = sub.add_parser("build", help="generate case STLs") @@ -51,9 +66,12 @@ def main(argv=None) -> int: build.add_argument("-c", "--config", help="TOML config file") build.add_argument("-o", "--out", default=".", help="output directory") build.add_argument("--name", help="output file prefix (overrides name)") - build.add_argument("--parts", default="left,right,stand", help="comma-separated parts") + build.add_argument( + "--parts", help="comma-separated parts (default: left,right, plus stand if enabled)" + ) - sub.add_parser("defaults", help="print the default config as TOML") + defaults = sub.add_parser("defaults", help="print a style's full config as TOML") + defaults.add_argument("--style", default=CaseConfig.style, choices=sorted(PRESETS)) args = parser.parse_args(argv) logging.basicConfig( @@ -62,10 +80,25 @@ def main(argv=None) -> int: ) if args.command == "defaults": - sys.stdout.write(defaults_toml()) + sys.stdout.write(defaults_toml(args.style)) return 0 - - cfg = load_config(args.config) if args.config else CaseConfig() + try: + return _build(args) + except ConfigError as e: + if args.verbose: + raise + print(f"scrollcase: config error: {e}", file=sys.stderr) + return 2 + except Exception as e: + if args.verbose: + raise + print(f"scrollcase: build failed: {e}", file=sys.stderr) + print("Run with -v for details.", file=sys.stderr) + return 1 + + +def _build(args) -> int: + cfg = load_config(args.config) if args.config else config_from_dict({}) if args.mesh: cfg.scroll.mesh = args.mesh if args.name: @@ -74,13 +107,17 @@ def main(argv=None) -> int: # Imported here so `defaults` works without loading meshlib from .pipeline import build as run_build - parts = [p.strip() for p in args.parts.split(",") if p.strip()] + parts = [p.strip() for p in args.parts.split(",") if p.strip()] if args.parts else None result = run_build(cfg, args.out, parts=parts) L = result.layout print(f"Scroll: {2 * L.scroll_radius:.2f} D x {L.scroll_height:.2f} H mm") print(f"Lining: {L.lining_diameter:.2f} D (outer) mm") - print(f"Shell: {L.inner_diameter:.2f} D (inner)") - print(f"Case: {L.outer_diameter:.2f} D x {L.outer_height:.2f} H mm") + if cfg.shell.type != "none": + print(f"Shell: {L.inner_diameter:.2f} D (inner), {L.outer_diameter:.2f} D (outer) mm") + if cfg.ends.type == "caps": + side = 2 * L.cap_half_width + print(f"Caps: {side:.2f} x {side:.2f} mm, plus bolt tabs") + print(f"Height: {L.outer_height:.2f} mm") for path in result.outputs.values(): print(f"Wrote {path}") return 0 diff --git a/scrollcase/src/scrollcase/config.py b/scrollcase/src/scrollcase/config.py index 1fe776f..83cc8fe 100644 --- a/scrollcase/src/scrollcase/config.py +++ b/scrollcase/src/scrollcase/config.py @@ -1,14 +1,24 @@ -"""Case configuration and derived layout. +"""Case configuration, style presets, and derived layout. Units are mm. Case coordinates: the case axis is +Z, the shell bottom is Z=0, and the halves split on the XZ plane (left is -Y, right is +Y). + +A config is resolved as: dataclass defaults, then the `style` preset, then +the user's values. Released presets never change; see `PRESETS`. """ +import copy import dataclasses +import math import tomllib from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from types import UnionType +from typing import Any, get_args, get_origin + + +class ConfigError(ValueError): + """A config the user can fix: bad keys, values, or combinations.""" @dataclass @@ -33,19 +43,73 @@ class ScrollConfig: generic_height: float = 155.0 +@dataclass +class SplitConfig: + """Surface the halves separate along, as a profile y = f(x) extruded in Z. + + "curve" is villa's S-shaped split spanning the whole case. `amplitude` + defaults to 0.2 x (span + wall_thickness), as upstream; `flip` mirrors it. + """ + + type: str = "plane" + amplitude: float | None = None + flip: bool = False + + @dataclass class HoneycombConfig: - enabled: bool = True hole_edges: int = 6 columns: int = 12 spacing: float = 1.5 +@dataclass +class ShellConfig: + """Outer cylinder around the lining. `open_top` cuts away its upper part. + + `marker_rings` is ignored when there's no shell. + """ + + type: str = "honeycomb" # "honeycomb", "solid", or "none" + open_top: bool = False + marker_rings: bool = True + honeycomb: HoneycombConfig = field(default_factory=HoneycombConfig) + + +@dataclass +class EndsConfig: + """What closes the case top and bottom. + + "shell" is the shell's own floor and lid. "caps" are villa's square end + caps, with bolt tabs that clamp the halves together (M4 by default) and + counterbored holes in the bottom cap for mounting (M6 by default). The + remaining keys only apply to caps. + """ + + type: str = "shell" + cap_height: float = 10.0 + corner_fillet: float = 6.25 + bolt_hole_diameter: float = 5.0 + bolt_counterbore_diameter: float = 8.0 + bolt_counterbore_depth: float = 2.0 + nut_diameter: float = 9.0 + nut_depth: float = 3.5 + mount_hole_spacing: float = 50.0 + mount_hole_diameter: float = 6.8 + mount_counterbore_diameter: float = 10.5 + mount_counterbore_depth: float = 5.0 + + +@dataclass +class MountConfig: + type: str = "generic-112.5" # "generic-112.5", "generic-65", "kinematic", or "none" + + @dataclass class NubConfig: """Alignment nubs on the left half, with matching sockets on the right. - `positions` are [x, z] pairs on the split plane, in case coordinates. + `positions` are [x, z] pairs on the split surface, in case coordinates. """ positions: list[list[float]] = field(default_factory=list) @@ -71,49 +135,185 @@ class LabelConfig: font: str = "Arial Rounded MT Bold" +@dataclass +class StandConfig: + enabled: bool = True + length_scale: float = 1.0 + + @dataclass class CaseConfig: + style: str = "educelab.v1" name: str = "Scroll Case" lining_offset: float = 2.0 wall_thickness: float = 2.0 bottom_buffer: float = 5.0 top_buffer: float = 5.0 internal_gap: float = 3.0 - outer_cylinder: bool = True overhang_removal: bool = True - marker_rings: bool = True - mount_disc: str = "112.5" # "112.5" or "65" - stand_length_scale: float = 1.0 voxel_size: float = 0.4 scroll: ScrollConfig = field(default_factory=ScrollConfig) - honeycomb: HoneycombConfig = field(default_factory=HoneycombConfig) + split: SplitConfig = field(default_factory=SplitConfig) + shell: ShellConfig = field(default_factory=ShellConfig) + ends: EndsConfig = field(default_factory=EndsConfig) + mount: MountConfig = field(default_factory=MountConfig) nubs: NubConfig = field(default_factory=NubConfig) escape_holes: EscapeHoleConfig = field(default_factory=EscapeHoleConfig) label: LabelConfig = field(default_factory=LabelConfig) + stand: StandConfig = field(default_factory=StandConfig) + + +# Released presets are frozen by tests/presets/