"""Wooden-train track generator (IKEA Lillabo / Brio / J'adore compatible). Wraps the train_tracks.scad library from https://github.com/torwanbukaj/ikea-brio-others-compatible-train-tracks-generator The .scad ships as a "library + a single active example call". We comment the example out locally and dynamically build a tiny wrapper SCAD per render request: include track(length=100, end1="plug", end2="nest", cutout=true, ...); Each "part" the user can generate has its own parameter schema (PART_SCHEMAS). The frontend renders a form per selected part; the backend builds the call and reuses holder.render_source() for the actual openscad invocation. """ from __future__ import annotations import json from pathlib import Path from typing import Any import holder # for render_source() and _to_scad_literal() APP_DIR = Path(__file__).resolve().parent SCAD_LIBRARY = APP_DIR / "scad" / "train_tracks.scad" # --------------------------------------------------------------------------- # Part schemas. # "module" — the SCAD module to invoke. # "params" — UI-driven inputs; each entry is the same shape the /holder # schema uses (name/label/kind/default/min/max/step/options/help). # "value_map" — optional name -> {ui_value: scad_value} translation for # select params whose label isn't what the SCAD wants # (e.g. bridge "ground" -> int 1). # --------------------------------------------------------------------------- PART_SCHEMAS: dict[str, dict] = { # ---------- Calibration ------------------------------------------------- "tester": { "label": "Track tester (calibration)", "module": "track_tester", "params": [ {"name": "tester_length", "label": "Tester length (mm)", "kind": "number", "default": 25, "min": 15, "max": 60, "step": 1, "help": "Small block — print first to verify plug/nest fit."}, ], }, # ---------- Straight ---------------------------------------------------- "track": { "label": "Straight track", "module": "track", "params": [ {"name": "length", "label": "Length (mm)", "kind": "number", "default": 100, "min": 20, "max": 500, "step": 5, "help": "IKEA standards: 50 / 100 / 146 / 205 / 240. Brio: 145."}, {"name": "end1", "label": "End 1", "kind": "select", "default": "plug", "options": ["plug", "nest", "none"]}, {"name": "end2", "label": "End 2", "kind": "select", "default": "nest", "options": ["plug", "nest", "none"]}, {"name": "cutout", "label": "Wheel cutout","kind": "bool", "default": True}, {"name": "grooves", "label": "Side grooves","kind": "bool", "default": True}, {"name": "part_chamfers", "label": "Corner chamfers", "kind": "bool", "default": True}, {"name": "both_sides", "label": "Both sides grooves", "kind": "bool", "default": False}, ], }, # ---------- Arc --------------------------------------------------------- "arc": { "label": "Arc / curved track", "module": "track_arc", "params": [ {"name": "radius", "label": "Radius (mm)", "kind": "number", "default": 185, "min": 50, "max": 500, "step": 1, "help": "IKEA Lillabo: 180–185. J'adore: 86. Brio U-turn: 86."}, {"name": "angle", "label": "Angle (deg)", "kind": "number", "default": 45, "min": -180, "max": 180, "step": 1, "help": "Negative angle curves the other direction. IKEA: 22.5 or 45."}, {"name": "end1", "label": "End 1", "kind": "select", "default": "plug", "options": ["plug", "nest", "none"]}, {"name": "end2", "label": "End 2", "kind": "select", "default": "nest", "options": ["plug", "nest", "none"]}, {"name": "grooves", "kind": "bool", "default": True}, {"name": "cutout", "kind": "bool", "default": True}, {"name": "both_sides", "kind": "bool", "default": False}, {"name": "part_chamfers", "kind": "bool", "default": True}, ], }, # ---------- Dog-bone connector ----------------------------------------- "dogbone": { "label": "Dog-bone (nest–nest connector)", "module": "track_dogbone", "params": [ {"name": "radius", "label": "Plug radius (mm)", "kind": "number", "default": 6.5, "min": 5.5, "max": 7.5, "step": 0.05, "help": "IKEA Lillabo / Brio: 6.5 (slightly oversized for springy fit)."}, ], }, # ---------- Intersection ----------------------------------------------- "intersection": { "label": "Intersection (crossing)", "module": "intersection", "params": [ {"name": "angle", "label": "Crossing angle (deg)", "kind": "number", "default": 90, "min": 30, "max": 150, "step": 1}, {"name": "lengthA", "label": "Length A (mm)", "kind": "number", "default": 100, "min": 50, "max": 300, "step": 5}, {"name": "lengthB", "label": "Length B (mm)", "kind": "number", "default": 100, "min": 50, "max": 300, "step": 5}, {"name": "end1A", "kind": "select", "default": "nest", "options": ["plug", "nest", "none"]}, {"name": "end2A", "kind": "select", "default": "plug", "options": ["plug", "nest", "none"]}, {"name": "end1B", "kind": "select", "default": "nest", "options": ["plug", "nest", "none"]}, {"name": "end2B", "kind": "select", "default": "plug", "options": ["plug", "nest", "none"]}, {"name": "both_sides", "kind": "bool", "default": True}, ], }, # ---------- Switch / turnout ------------------------------------------- "switch": { "label": "Switch (turnout)", "module": "switch", "params": [ {"name": "angleR", "label": "Right angle (deg)", "kind": "number", "default": 45, "min": 10, "max": 90, "step": 1}, {"name": "radiusR", "label": "Right radius (mm)", "kind": "number", "default": 86, "min": 50, "max": 300, "step": 1}, {"name": "endR", "label": "Right end", "kind": "select", "default": "nest", "options": ["plug", "nest"]}, {"name": "angleL", "label": "Left angle (deg)", "kind": "number", "default": 45, "min": 10, "max": 90, "step": 1}, {"name": "radiusL", "label": "Left radius (mm)", "kind": "number", "default": 86, "min": 50, "max": 300, "step": 1}, {"name": "endL", "label": "Left end", "kind": "select", "default": "plug", "options": ["plug", "nest"]}, {"name": "lengthS", "label": "Straight length (mm)", "kind": "number", "default": 146, "min": 50, "max": 300, "step": 1}, {"name": "endS", "label": "Straight end", "kind": "select", "default": "nest", "options": ["plug", "nest"]}, {"name": "endCommon", "label": "Common (junction) end", "kind": "select", "default": "nest", "options": ["plug", "nest"]}, {"name": "both_sides", "kind": "bool", "default": True}, ], }, # ---------- Snake track ------------------------------------------------ "snake": { "label": "Snake (S-curve)", "module": "snake_track", "params": [ {"name": "angle", "kind": "number", "default": 45, "min": 10, "max": 90, "step": 1}, {"name": "radius", "kind": "number", "default": 86, "min": 50, "max": 300, "step": 1}, {"name": "target_length", "label": "Target length (mm)", "kind": "number", "default": 200, "min": 50, "max": 400, "step": 5, "help": "Must be larger than the S-curve's natural Y-span (about 150mm for angle=45, radius=86)."}, {"name": "end1", "kind": "select", "default": "plug", "options": ["plug", "nest", "none"]}, {"name": "end2", "kind": "select", "default": "nest", "options": ["plug", "nest", "none"]}, {"name": "cutout", "kind": "bool", "default": True}, {"name": "both_sides", "kind": "bool", "default": True}, ], }, # ---------- Adapter (BRIO <-> IKEA) ------------------------------------ "adapter": { "label": "Adapter (BRIO ↔ IKEA)", "module": "tracks_adapter", "params": [ {"name": "length", "kind": "number", "default": 30, "min": 20, "max": 80, "step": 1}, {"name": "nest", "label": "Nest side system", "kind": "select", "default": "B", "options": ["I", "B"], "help": "I = IKEA Lillabo, B = Brio."}, {"name": "plug", "label": "Plug side system", "kind": "select", "default": "I", "options": ["I", "B"]}, ], }, # ---------- Bridge ----------------------------------------------------- "bridge": { "label": "Bridge (multi-part)", "module": "generate_bridge", "params": [ {"name": "what_to_generate", "label": "Part", "kind": "select", "default": "ground", "options": ["overview", "ground", "slope", "pillar"], "help": "Print each part separately; overview shows them together for sizing."}, {"name": "bridge_angle", "label": "Slope angle (deg)", "kind": "number", "default": 14, "min": 5, "max": 30, "step": 1}, {"name": "slope_radius", "label": "Slope radius (mm)", "kind": "number", "default": 100, "min": 50, "max": 300, "step": 5}, {"name": "straight_part_l", "label": "Straight part (mm)", "kind": "number", "default": 205, "min": 50, "max": 400, "step": 5}, {"name": "pillar_l", "label": "Pillar length (mm)", "kind": "number", "default": 50, "min": 20, "max": 200, "step": 5}, {"name": "cutout", "kind": "bool", "default": True}, ], "value_map": { # SCAD takes int 0..3; UI uses labels. "what_to_generate": {"overview": 0, "ground": 1, "slope": 2, "pillar": 3}, }, }, } def schema_dict() -> dict: """Public API shape returned by /api/tracks/params.""" parts = [] for key, spec in PART_SCHEMAS.items(): defaults = {p["name"]: p["default"] for p in spec["params"]} parts.append({ "key": key, "label": spec["label"], "module": spec["module"], "params": spec["params"], "defaults": defaults, }) return {"parts": parts} # --------------------------------------------------------------------------- # Wrapper-SCAD builder # --------------------------------------------------------------------------- def _to_scad_literal(v: Any) -> str: # Defer to holder's literal formatter for consistency (bools lowercased, # ints stay int-like, strings get JSON-quoted). return holder._to_scad_literal(v) def build_wrapper_scad(part: str, params: dict | None) -> str: spec = PART_SCHEMAS.get(part) if not spec: raise ValueError(f"unknown track part: {part!r}") value_map = spec.get("value_map", {}) schema_by_name = {p["name"]: p for p in spec["params"]} args: list[str] = [] incoming = params or {} for p in spec["params"]: name = p["name"] if name in incoming and incoming[name] is not None: v = incoming[name] else: v = p["default"] # Coerce if p["kind"] == "number": v = float(v) if v.is_integer(): v = int(v) elif p["kind"] == "bool": v = bool(v) elif p["kind"] == "select": v = str(v) # Apply optional label -> SCAD value translation if name in value_map and v in value_map[name]: v = value_map[name][v] args.append(f"{name} = {_to_scad_literal(v)}") # Path relative to the temp file holder.render_source writes — it puts # the file in a fresh tempdir, so resolve absolute path for `include`. return ( f'include <{SCAD_LIBRARY.as_posix()}>\n' f'{spec["module"]}({", ".join(args)});\n' ) def render(part: str, params: dict | None = None) -> tuple[bytes, dict]: """Render the chosen track part with the supplied params.""" src = build_wrapper_scad(part, params) return holder.render_source(src)