102cfcee64
Wraps torwanbukaj/ikea-brio-others-compatible-train-tracks-generator ( https://github.com/torwanbukaj/... ) as a third generator alongside holder and the universal scad playground. Approach -------- The upstream .scad ships as "library + one active example call". Used include<> (rather than use<>) so the library's top-level globals (track_width, plug/nest dimensions, $fn = 150, etc.) are available to the modules — `use<>` does NOT propagate variables. Commented out the upstream `track_tester();` call so include<> doesn't also emit the tester geometry every time. Per-render the backend builds a tiny wrapper SCAD on the fly: include <scad/train_tracks.scad> track(length=100, end1="plug", end2="nest", cutout=true, ...); and hands it to holder.render_source(). Part types exposed (9) ---------------------- - tester Calibration block (15-60mm) - track Straight (length + chamfers + grooves + plug/nest ends) - arc Curved (radius + angle, IKEA/Brio/J'adore radii in help text) - dogbone Nest-nest connector - intersection Crossing (angle + 2 lengths + 4 ends) - switch Turnout (left/right radius+angle, straight branch, common end) - snake S-curve (raised default target_length to 200 — the upstream modules's natural curvy-span at angle=45/radius=86 is ~150 and the assert fires below that) - adapter BRIO <-> IKEA system adapter (plug + nest side) - bridge Multi-part (overview/ground/slope/pillar — value_map translates the UI labels to the int 0..3 the SCAD wants) Files ----- - scad/train_tracks.scad Downloaded upstream (57 999 bytes), only modification is the // before the top-level track_tester() call. - tracks.py PART_SCHEMAS dict, build_wrapper_scad, render() that delegates to render_source. - app.py GET /api/tracks/params, POST /api/tracks/render, GET /tracks page route. - static/tracks.html Page with part selector + dynamic param form + shared viewer markup. Reuses holder.css. - static/js/tracks-app.js Controller. Switching the part select redraws the form (each part has its own schema). Ctrl+Enter renders, Cancel uses AbortController. Nav --- Tracks link added to topbar on holder / index / scad. Smoke test ---------- All 9 parts render with default params on the Manifold backend in under 0.5s each (output sizes 440 KB - 1.8 MB).
275 lines
13 KiB
Python
275 lines
13 KiB
Python
"""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 <scad/train_tracks.scad>
|
||
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)
|