From 3418e0168946c2281788952a03ad2e2086df2d14 Mon Sep 17 00:00:00 2001 From: wenil Date: Mon, 25 May 2026 11:49:28 +0300 Subject: [PATCH] holder: cap total cells at 1000 to prevent runaway renders User-triggered 120x120 = 14400 cells, which produces huge STL/long renders. Total cells is the right metric (CSG cost scales with count, not max axis), so cap by N = rows*cols (or rows*(rows+1)/2 for tria style). 1000 covers any realistic pack (e.g. 20x50) while blocking accidental misuse. Backend: - holder.py: MAX_CELLS env-tunable (default 1000); expected_cell_count and _check_cell_limit raise ValueError on exceed; both compute_cells() and render_stl() call it up-front. - app.py: /api/holder/render now returns 400 on ValueError (not 500) so the frontend can distinguish bad input from server failure. /api/holder/params now publishes max_cells alongside the schema. Frontend: - holder-app.js: reads max_cells from the params endpoint; status shows "N cells / over limit (1000)" in red and disables the Render and "Design busbars" buttons when exceeded. - holder.css: .topbar-status.over-limit style (red, bold). --- app.py | 8 +++++++- holder.py | 23 +++++++++++++++++++++++ static/holder.css | 6 ++++++ static/js/holder-app.js | 15 +++++++++++++-- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 441fd75..d5745c8 100644 --- a/app.py +++ b/app.py @@ -56,7 +56,11 @@ def health(): @app.get("/api/holder/params") def holder_params(): """Schema for the parameter form + their defaults.""" - return jsonify({"params": holder.schema_dict(), "defaults": holder.default_params()}) + return jsonify({ + "params": holder.schema_dict(), + "defaults": holder.default_params(), + "max_cells": holder.MAX_CELLS, + }) @app.post("/api/holder/render") @@ -65,6 +69,8 @@ def holder_render(): body = request.get_json(silent=True) or {} try: data = holder.render_stl(body.get("params", {})) + except ValueError as e: + return jsonify({"error": str(e)}), 400 except (FileNotFoundError, RuntimeError) as e: return jsonify({"error": str(e)}), 500 return Response( diff --git a/holder.py b/holder.py index 8e518ee..81aff00 100644 --- a/holder.py +++ b/holder.py @@ -31,6 +31,9 @@ OPENSCAD_BIN = os.environ.get("OPENSCAD_BIN", "openscad") # Empty string disables; default Manifold gives ~10-50x speedup on OpenSCAD 2024+ OPENSCAD_BACKEND = os.environ.get("OPENSCAD_BACKEND", "Manifold") RENDER_TIMEOUT = int(os.environ.get("OPENSCAD_TIMEOUT", "300")) +# Cap total cells to keep renders fast and STL sizes sane. 1000 covers +# real packs (e.g. 20×50 = 1000) while blocking accidental 120×120 = 14400. +MAX_CELLS = int(os.environ.get("HOLDER_MAX_CELLS", "1000")) def openscad_available() -> bool: @@ -126,7 +129,26 @@ def schema_dict() -> list[dict]: _COS30 = math.cos(math.radians(30)) +def expected_cell_count(params: dict) -> int: + """Cell count for a pack without computing coordinates.""" + rows = int(params.get("num_rows", 6)) + cols = int(params.get("num_cols", 12)) + style = str(params.get("pack_style", "rect")) + if style == "tria": + return rows * (rows + 1) // 2 + return rows * cols + + +def _check_cell_limit(params: dict) -> None: + n = expected_cell_count(params) + if n > MAX_CELLS: + raise ValueError( + f"Too many cells ({n} > {MAX_CELLS}). Reduce rows × cols." + ) + + def compute_cells(params: dict) -> list[dict]: + _check_cell_limit(params) cell_dia = float(params.get("cell_dia", 21.2)) wall = float(params.get("wall", 0.8)) rows = int(params.get("num_rows", 6)) @@ -200,6 +222,7 @@ def _filter_params(params: dict) -> dict: def render_stl(params: dict) -> bytes: """Render the .scad with given parameter overrides; return STL bytes.""" + _check_cell_limit(params) if not openscad_available(): raise RuntimeError( f"`{OPENSCAD_BIN}` not found on PATH. Install OpenSCAD " diff --git a/static/holder.css b/static/holder.css index aebefb6..7ee82a3 100644 --- a/static/holder.css +++ b/static/holder.css @@ -105,3 +105,9 @@ #btn-render.dirty { box-shadow: 0 0 0 2px var(--accent, #4a9eff) inset; } + +/* Cell-count over the server limit — render is disabled, status flagged */ +.topbar-status.over-limit { + color: var(--danger, #d94a4a); + font-weight: 600; +} diff --git a/static/js/holder-app.js b/static/js/holder-app.js index 1376958..03c536f 100644 --- a/static/js/holder-app.js +++ b/static/js/holder-app.js @@ -19,6 +19,7 @@ const state = { abortCtrl: null, // for cancelling in-flight render elapsedTimer: null, rendererReady: false, + maxCells: 1000, // server-published cap; overridden by /api/holder/params }; // ----- viewer init ---------------------------------------------------------- @@ -218,7 +219,16 @@ function _updateStatus() { let n; if (style === "tria") n = rows * (rows + 1) / 2; else n = rows * cols; - $("status").textContent = `${n} cells`; + const status = $("status"); + const overLimit = n > state.maxCells; + status.textContent = overLimit + ? `${n} cells · over limit (${state.maxCells})` + : `${n} cells`; + status.classList.toggle("over-limit", overLimit); + // Block render when over the cap. Also blocks "Design busbars →" + // since it'd hit the same limit on the server. + $("btn-render").disabled = overLimit; + $("btn-to-busbar").disabled = overLimit; } // ----- buttons -------------------------------------------------------------- @@ -299,9 +309,10 @@ async function init() { try { const r = await fetch("/api/holder/params"); if (!r.ok) throw new Error(r.statusText); - const { params: schema, defaults } = await r.json(); + const { params: schema, defaults, max_cells } = await r.json(); state.schema = schema; state.params = { ...defaults }; + if (typeof max_cells === "number") state.maxCells = max_cells; _renderForm(schema, defaults); _updateStatus(); } catch (e) {