From 74581778e58382ee8806ce0fee3bbc99cde57169 Mon Sep 17 00:00:00 2001 From: Seth Parker Date: Thu, 1 Oct 2026 16:46:36 -0400 Subject: [PATCH 01/10] Restructure scrollcase config into components and style presets Replace the flat case flags with typed sections ([split], [shell], [ends], [mount], [stand]) and resolve configs as dataclass defaults, then a named style preset, then user values. educelab.v1 is the current design and the permanent default; golden files in tests/presets freeze each released preset. Invalid component combinations are rejected when the config loads. No geometry change: generic, fully featured, solid/65 mm, and PHercParis3 builds match the previous outputs exactly. Co-Authored-By: Claude Opus 5.5 --- docs/creating-scroll-case.md | 2 +- docs/scroll-case-config.md | 90 +++++++++++++--- scrollcase/examples/generic.toml | 5 +- scrollcase/src/scrollcase/case.py | 20 ++-- scrollcase/src/scrollcase/cli.py | 52 +++++---- scrollcase/src/scrollcase/config.py | 122 +++++++++++++++++++--- scrollcase/src/scrollcase/mount_disc.py | 5 +- scrollcase/src/scrollcase/pipeline.py | 21 +++- scrollcase/tests/presets/educelab.v1.toml | 63 +++++++++++ scrollcase/tests/test_case.py | 2 +- scrollcase/tests/test_config.py | 52 +++++++-- scrollcase/tests/test_pipeline.py | 16 +-- 12 files changed, 372 insertions(+), 78 deletions(-) create mode 100644 scrollcase/tests/presets/educelab.v1.toml diff --git a/docs/creating-scroll-case.md b/docs/creating-scroll-case.md index bfb9eb2..51de2ba 100644 --- a/docs/creating-scroll-case.md +++ b/docs/creating-scroll-case.md @@ -20,7 +20,7 @@ honeycomb case: left half (with mount disc), right half, and assembly stand. ```bash cd scrollcase - uv run scrollcase defaults > my-scroll.toml + uv run scrollcase defaults --style educelab.v1 > my-scroll.toml ``` Set `name`, `scroll.mesh` (relative to the config file), and the label. diff --git a/docs/scroll-case-config.md b/docs/scroll-case-config.md index 7db1204..f87e089 100644 --- a/docs/scroll-case-config.md +++ b/docs/scroll-case-config.md @@ -1,15 +1,31 @@ # 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. + +| Style | Description | +|---|---| +| `educelab.v1` | The default: honeycomb shell with floor and lid, flat split, Generic Mount Disc, stand. The former OpenSCAD generator's design. | + +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 +36,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,18 +63,51 @@ 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. + +| Key | Default | Description | +|---|---|---| +| `type` | `"plane"` | `"plane"`: the XZ plane. | + +## `[shell]` + +The outer cylinder around the lining, including its floor and lid. + +| Key | Default | Description | +|---|---|---| +| `type` | `"honeycomb"` | `"honeycomb"` (hexagonal cutouts) or `"solid"`. | +| `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. | + +### `[shell.honeycomb]` -Hexagonal cutouts in the shell. The floor, lid, marker ring bands, and -divider stay solid. +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. | + +## `[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`. | + ## `[nubs]` Alignment nubs on the left half's split face, with matching sockets on the @@ -103,6 +149,26 @@ 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 | +|---|---| +| `shell.marker_rings` requires `shell.type = "honeycomb"` | The rings fill bands of the honeycomb. | +| `shell.open_top` requires `ends.type = "shell"` | It cuts away the shell's lid. | +| `stand.enabled` requires a `generic-*` mount | The cradle is shaped around the Generic Mount Disc and its notch. | + ## How the dimensions stack up From the fitted scroll radius `r` and height `h`: 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/src/scrollcase/case.py b/scrollcase/src/scrollcase/case.py index 84cea83..154c75d 100644 --- a/scrollcase/src/scrollcase/case.py +++ b/scrollcase/src/scrollcase/case.py @@ -47,7 +47,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 @@ -89,7 +89,7 @@ def _honeycomb_cutter(cfg: CaseConfig, L: Layout) -> Part: d = L.outer_diameter mid_h = L.outer_height - L.wall - 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( @@ -105,13 +105,13 @@ def shell(cfg: CaseConfig, L: Layout) -> Part: hollow = translate([0, 0, L.inner_z], cylinder(L.inner_height, L.inner_diameter / 2)) body = outer - hollow - if cfg.honeycomb.enabled: + if cfg.shell.type == "honeycomb": body -= _honeycomb_cutter(cfg, L) - if cfg.marker_rings: + 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] @@ -177,7 +177,7 @@ def _nub_box(size: float, depth: float) -> Part: def left_body(cfg: CaseConfig, L: Layout) -> Part: - disc = MOUNT_DISCS[cfg.mount_disc] + disc = MOUNT_DISCS[cfg.mount.type] body = (shell(cfg, L) + divider(L)) & _half_space("left") body += disc.solid() @@ -196,7 +196,7 @@ def left_body(cfg: CaseConfig, L: Layout) -> Part: def right_body(cfg: CaseConfig, L: Layout) -> Part: - disc = MOUNT_DISCS[cfg.mount_disc] + disc = MOUNT_DISCS[cfg.mount.type] body = (shell(cfg, L) + divider(L)) & _half_space("right") n = cfg.nubs @@ -233,8 +233,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 +255,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..a5212f8 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, config_from_dict, load_config def _toml_value(value) -> str: @@ -22,25 +22,32 @@ 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"') +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: + hint = '"path/to/mesh.ply"' if key == "mesh" else "..." + lines.append(f"# {key} = {hint}") 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") @@ -51,9 +58,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 +72,10 @@ 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() + cfg = load_config(args.config) if args.config else config_from_dict({}) if args.mesh: cfg.scroll.mesh = args.mesh if args.name: @@ -74,7 +84,7 @@ 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") diff --git a/scrollcase/src/scrollcase/config.py b/scrollcase/src/scrollcase/config.py index 1fe776f..2ad22f0 100644 --- a/scrollcase/src/scrollcase/config.py +++ b/scrollcase/src/scrollcase/config.py @@ -1,9 +1,13 @@ -"""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 tomllib from dataclasses import dataclass, field @@ -33,19 +37,47 @@ class ScrollConfig: generic_height: float = 155.0 +@dataclass +class SplitConfig: + """Surface the halves separate along, as a profile y = f(x) extruded in Z.""" + + type: str = "plane" + + @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.""" + + type: str = "honeycomb" # "honeycomb" or "solid" + 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 floor and lid.""" + + type: str = "shell" + + +@dataclass +class MountConfig: + type: str = "generic-112.5" # "generic-112.5" or "generic-65" + + @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,43 +103,109 @@ 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/