Files
busbar-designer/tracks.py
T
wenil 102cfcee64 tracks: new /tracks generator for IKEA Lillabo / Brio train tracks
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).
2026-05-25 13:38:29 +03:00

275 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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: 180185. 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 (nestnest 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)