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).
This commit is contained in:
@@ -56,7 +56,11 @@ def health():
|
|||||||
@app.get("/api/holder/params")
|
@app.get("/api/holder/params")
|
||||||
def holder_params():
|
def holder_params():
|
||||||
"""Schema for the parameter form + their defaults."""
|
"""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")
|
@app.post("/api/holder/render")
|
||||||
@@ -65,6 +69,8 @@ def holder_render():
|
|||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
try:
|
try:
|
||||||
data = holder.render_stl(body.get("params", {}))
|
data = holder.render_stl(body.get("params", {}))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
except (FileNotFoundError, RuntimeError) as e:
|
except (FileNotFoundError, RuntimeError) as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ OPENSCAD_BIN = os.environ.get("OPENSCAD_BIN", "openscad")
|
|||||||
# Empty string disables; default Manifold gives ~10-50x speedup on OpenSCAD 2024+
|
# Empty string disables; default Manifold gives ~10-50x speedup on OpenSCAD 2024+
|
||||||
OPENSCAD_BACKEND = os.environ.get("OPENSCAD_BACKEND", "Manifold")
|
OPENSCAD_BACKEND = os.environ.get("OPENSCAD_BACKEND", "Manifold")
|
||||||
RENDER_TIMEOUT = int(os.environ.get("OPENSCAD_TIMEOUT", "300"))
|
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:
|
def openscad_available() -> bool:
|
||||||
@@ -126,7 +129,26 @@ def schema_dict() -> list[dict]:
|
|||||||
_COS30 = math.cos(math.radians(30))
|
_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]:
|
def compute_cells(params: dict) -> list[dict]:
|
||||||
|
_check_cell_limit(params)
|
||||||
cell_dia = float(params.get("cell_dia", 21.2))
|
cell_dia = float(params.get("cell_dia", 21.2))
|
||||||
wall = float(params.get("wall", 0.8))
|
wall = float(params.get("wall", 0.8))
|
||||||
rows = int(params.get("num_rows", 6))
|
rows = int(params.get("num_rows", 6))
|
||||||
@@ -200,6 +222,7 @@ def _filter_params(params: dict) -> dict:
|
|||||||
|
|
||||||
def render_stl(params: dict) -> bytes:
|
def render_stl(params: dict) -> bytes:
|
||||||
"""Render the .scad with given parameter overrides; return STL bytes."""
|
"""Render the .scad with given parameter overrides; return STL bytes."""
|
||||||
|
_check_cell_limit(params)
|
||||||
if not openscad_available():
|
if not openscad_available():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"`{OPENSCAD_BIN}` not found on PATH. Install OpenSCAD "
|
f"`{OPENSCAD_BIN}` not found on PATH. Install OpenSCAD "
|
||||||
|
|||||||
@@ -105,3 +105,9 @@
|
|||||||
#btn-render.dirty {
|
#btn-render.dirty {
|
||||||
box-shadow: 0 0 0 2px var(--accent, #4a9eff) inset;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+13
-2
@@ -19,6 +19,7 @@ const state = {
|
|||||||
abortCtrl: null, // for cancelling in-flight render
|
abortCtrl: null, // for cancelling in-flight render
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
rendererReady: false,
|
rendererReady: false,
|
||||||
|
maxCells: 1000, // server-published cap; overridden by /api/holder/params
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----- viewer init ----------------------------------------------------------
|
// ----- viewer init ----------------------------------------------------------
|
||||||
@@ -218,7 +219,16 @@ function _updateStatus() {
|
|||||||
let n;
|
let n;
|
||||||
if (style === "tria") n = rows * (rows + 1) / 2;
|
if (style === "tria") n = rows * (rows + 1) / 2;
|
||||||
else n = rows * cols;
|
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 --------------------------------------------------------------
|
// ----- buttons --------------------------------------------------------------
|
||||||
@@ -299,9 +309,10 @@ async function init() {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch("/api/holder/params");
|
const r = await fetch("/api/holder/params");
|
||||||
if (!r.ok) throw new Error(r.statusText);
|
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.schema = schema;
|
||||||
state.params = { ...defaults };
|
state.params = { ...defaults };
|
||||||
|
if (typeof max_cells === "number") state.maxCells = max_cells;
|
||||||
_renderForm(schema, defaults);
|
_renderForm(schema, defaults);
|
||||||
_updateStatus();
|
_updateStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user