diff --git a/app.py b/app.py index d959572..102c577 100644 --- a/app.py +++ b/app.py @@ -44,6 +44,12 @@ def holder_page(): return send_from_directory(STATIC_DIR, "holder.html") +@app.get("/scad") +def scad_page(): + """Universal OpenSCAD renderer (paste any source, get STL).""" + return send_from_directory(STATIC_DIR, "scad.html") + + @app.get("/api/health") def health(): return jsonify({"status": "ok", "openscad": holder.openscad_available()}) @@ -64,9 +70,21 @@ def holder_params(): }) +def _stl_response(data: bytes, dims: dict, filename: str) -> Response: + return Response( + data, + mimetype="model/stl", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "X-Holder-Dimensions": json.dumps(dims), + "Access-Control-Expose-Headers": "X-Holder-Dimensions", + }, + ) + + @app.post("/api/holder/render") def holder_render(): - """Render STL for the supplied parameter overrides.""" + """Render STL for the bundled hex holder with parameter overrides.""" body = request.get_json(silent=True) or {} try: data, dims = holder.render_stl(body.get("params", {})) @@ -74,19 +92,34 @@ def holder_render(): return jsonify({"error": str(e)}), 400 except (FileNotFoundError, RuntimeError) as e: return jsonify({"error": str(e)}), 500 - return Response( - data, - mimetype="model/stl", - headers={ - "Content-Disposition": 'attachment; filename="hex_holder.stl"', - # ECHO values from hex_cell.scad (pack/box dimensions). Used by - # the viewer to draw a max-bounding-box ghost for fit-check. - "X-Holder-Dimensions": json.dumps(dims), - # Make the custom header visible to fetch() in any CORS/proxy - # setup (Access-Control-Expose-Headers; harmless on same-origin). - "Access-Control-Expose-Headers": "X-Holder-Dimensions", - }, - ) + return _stl_response(data, dims, "hex_holder.stl") + + +@app.get("/api/holder/source") +def holder_source(): + """Return the bundled hex_cell.scad text (for editor pre-population).""" + try: + return Response(holder.bundled_source(), mimetype="text/plain; charset=utf-8") + except FileNotFoundError as e: + return jsonify({"error": str(e)}), 500 + + +@app.post("/api/scad/render") +def scad_render(): + """Render arbitrary user-supplied OpenSCAD source (no holder schema). + + Body: {"source": "...", "params": {optional -D overrides}} + """ + body = request.get_json(silent=True) or {} + source = body.get("source") + params = body.get("params") # may be None + try: + data, dims = holder.render_source(source, params) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except (FileNotFoundError, RuntimeError) as e: + return jsonify({"error": str(e)}), 500 + return _stl_response(data, dims, "rendered.stl") @app.post("/api/holder/cells") diff --git a/holder.py b/holder.py index c00cdf3..5a79ac3 100644 --- a/holder.py +++ b/holder.py @@ -338,30 +338,26 @@ def _parse_echo_dims(stderr_bytes: bytes) -> dict: return out -def render_stl(params: dict) -> tuple[bytes, dict]: - """Render the .scad with given parameter overrides. +MAX_SOURCE_BYTES = int(os.environ.get("OPENSCAD_MAX_SOURCE_BYTES", str(512 * 1024))) - Returns (stl_bytes, dimensions) where dimensions is a {name: float} - dict harvested from openscad ECHO output (pack/box/holder sizes). - """ - _check_cell_limit(params) + +def _run_openscad(scad_path: Path, params: dict | None) -> tuple[bytes, dict]: if not openscad_available(): raise RuntimeError( f"`{OPENSCAD_BIN}` not found on PATH. Install OpenSCAD " "(e.g. `apt install openscad` on Debian/Ubuntu)." ) - if not SCAD_FILE.is_file(): - raise FileNotFoundError(f"SCAD source not found at {SCAD_FILE}") - - clean = _filter_params(params) with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "out.stl" cmd = [OPENSCAD_BIN, "-o", str(out)] if OPENSCAD_BACKEND: cmd += ["--backend", OPENSCAD_BACKEND] - for k, v in clean.items(): - cmd += ["-D", f"{k}={_to_scad_literal(v)}"] - cmd.append(str(SCAD_FILE)) + # Only the bundled-schema render passes -D overrides; raw source + # may not know about those variable names. + if params is not None: + for k, v in _filter_params(params).items(): + cmd += ["-D", f"{k}={_to_scad_literal(v)}"] + cmd.append(str(scad_path)) try: r = subprocess.run(cmd, capture_output=True, timeout=RENDER_TIMEOUT) except subprocess.TimeoutExpired: @@ -372,3 +368,42 @@ def render_stl(params: dict) -> tuple[bytes, dict]: if not out.exists() or out.stat().st_size == 0: raise RuntimeError("openscad produced no STL (geometry empty?)") return out.read_bytes(), _parse_echo_dims(r.stderr or b"") + + +def render_stl(params: dict) -> tuple[bytes, dict]: + """Render the bundled hex_cell.scad with parameter overrides. + + Enforces the holder cell-count cap (PARAMS-schema is assumed). For + rendering arbitrary user-supplied OpenSCAD source, use render_source. + """ + _check_cell_limit(params) + if not SCAD_FILE.is_file(): + raise FileNotFoundError(f"SCAD source not found at {SCAD_FILE}") + return _run_openscad(SCAD_FILE, params) + + +def render_source(source: str, params: dict | None = None) -> tuple[bytes, dict]: + """Render arbitrary user-supplied OpenSCAD source. + + Source size is capped (MAX_SOURCE_BYTES). Cell-count limit does NOT + apply — the source defines its own variables. RENDER_TIMEOUT still + bounds runaway computation. + """ + if not isinstance(source, str) or not source.strip(): + raise ValueError("Empty SCAD source.") + src_bytes = source.encode("utf-8", errors="replace") + if len(src_bytes) > MAX_SOURCE_BYTES: + raise ValueError( + f"SCAD source too large ({len(src_bytes)} > {MAX_SOURCE_BYTES} bytes)." + ) + with tempfile.TemporaryDirectory() as tmp: + scad = Path(tmp) / "user.scad" + scad.write_bytes(src_bytes) + return _run_openscad(scad, params) + + +def bundled_source() -> str: + """Read the bundled hex_cell.scad for editor pre-population.""" + if not SCAD_FILE.is_file(): + raise FileNotFoundError(f"SCAD source not found at {SCAD_FILE}") + return SCAD_FILE.read_text(encoding="utf-8", errors="replace") diff --git a/static/holder.css b/static/holder.css index 511e78d..6e0fc4c 100644 --- a/static/holder.css +++ b/static/holder.css @@ -165,3 +165,42 @@ #fit-verdict.fit-ok { color: #4ad97a; font-weight: 600; } #fit-verdict.fit-bad { color: var(--danger, #d94a4a); font-weight: 600; } + +/* SCAD editor — slides up from the bottom of the viewport */ +.scad-toggle { + position: absolute; + bottom: 8px; + right: 8px; + font-size: 12px; + padding: 4px 10px; + z-index: 3; +} +.scad-panel { + position: absolute; + left: 0; right: 0; bottom: 0; + height: 45%; + background: var(--bg, #11141a); + border-top: 1px solid var(--border, #2a2f3a); + display: flex; + flex-direction: column; + z-index: 2; +} +.scad-panel-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--border, #2a2f3a); + font-size: 12px; +} +.scad-panel-title { flex: 1; color: var(--muted); } +.scad-dirty { color: var(--accent, #4a9eff); font-weight: 600; } +#scad-editor { + flex: 1; + overflow: hidden; + font-size: 12px; +} +#scad-editor .CodeMirror { + height: 100%; + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; +} diff --git a/static/holder.html b/static/holder.html index 71c3616..57d45f8 100644 --- a/static/holder.html +++ b/static/holder.html @@ -6,6 +6,9 @@