Add hex holder designer page (/holder)
Server-side OpenSCAD renders STL from bundled hex_cell.scad with parameter
overrides via -D. Frontend is a Three.js viewer with auto-form generated
from /api/holder/params. 'Design busbars →' button posts the computed
cell coordinates to /api/projects and redirects to the busbar editor with
the holder cells pre-loaded.
- holder.py: openscad subprocess wrapper + compute_cells()
(Python mirror of get_hex_center_points_*)
- scad/hex_cell.scad: verbatim copy of Addy/Hex-Cell-Holder source
- app.py: /holder route + /api/holder/{params,render,cells}
- static/holder.html etc: parameter form + Three.js STL viewer
- Dockerfile / install.sh: apt install openscad
- static/index.html: nav link Holder ↔ Busbars in topbar
This commit is contained in:
@@ -12,10 +12,15 @@ The hard requirement is a working **STEP export**. STEP is generated by OpenCASC
|
|||||||
|
|
||||||
```
|
```
|
||||||
Browser (static/) ──fetch──> Flask (app.py)
|
Browser (static/) ──fetch──> Flask (app.py)
|
||||||
├── busbar_export.py → build123d → STEP/DXF/SVG
|
├── busbar_export.py → build123d → STEP/DXF/SVG (busbars page)
|
||||||
|
├── holder.py → openscad subprocess → STL (holder page)
|
||||||
└── storage.py → SQLite (projects/presets/snapshots)
|
└── storage.py → SQLite (projects/presets/snapshots)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Two pages:
|
||||||
|
- `/` → busbar designer (cell-import, panel/wire shapes, export STEP)
|
||||||
|
- `/holder` → hex-holder designer (parameter form, openscad render, STL download, "Design busbars →" hand-off)
|
||||||
|
|
||||||
- **Frontend** is plain HTML + JS (no build step). State lives in memory but is auto-synced to the server every 1.5 s; the active project ID is in the URL (`?p=42`) so refresh and other devices resume the same state.
|
- **Frontend** is plain HTML + JS (no build step). State lives in memory but is auto-synced to the server every 1.5 s; the active project ID is in the URL (`?p=42`) so refresh and other devices resume the same state.
|
||||||
- **Backend** is a Flask app with three groups of endpoints:
|
- **Backend** is a Flask app with three groups of endpoints:
|
||||||
- **CAD export**: `POST /api/export/{step,dxf,svg}` — stateless; takes a busbar payload and returns bytes via build123d.
|
- **CAD export**: `POST /api/export/{step,dxf,svg}` — stateless; takes a busbar payload and returns bytes via build123d.
|
||||||
@@ -76,10 +81,14 @@ If the user reports coordinates "off by half a hex," check `rect`-vs-`para` and
|
|||||||
|
|
||||||
## File map
|
## File map
|
||||||
|
|
||||||
- `app.py` — Flask app. Serves `static/`, dispatches export endpoints, exposes REST CRUD for projects/presets/snapshots. Single-file; no blueprints.
|
- `app.py` — Flask app. Serves `static/`, dispatches export endpoints, exposes REST CRUD for projects/presets/snapshots/holder. Single-file; no blueprints.
|
||||||
- `busbar_export.py` — pure functions. Input: payload dict. Output: bytes. No Flask import here.
|
- `busbar_export.py` — pure functions. Input: payload dict. Output: bytes. No Flask import here.
|
||||||
|
- `holder.py` — `subprocess.run(["openscad", "-D ...", "hex_cell.scad"])` wrapper that returns STL bytes; also has `compute_cells()` Python-side (mirrors the SCAD formulas) so the busbar hand-off doesn't need a second openscad call.
|
||||||
|
- `scad/hex_cell.scad` — verbatim copy of Addy777/Hex-Cell-Holder script. Source of truth for hex holder geometry. Update via git (no submodule for now).
|
||||||
- `storage.py` — SQLite layer for projects, presets, snapshots. Connection-per-call inside a context manager; no Flask import.
|
- `storage.py` — SQLite layer for projects, presets, snapshots. Connection-per-call inside a context manager; no Flask import.
|
||||||
- `static/index.html` — single page; left sidebar (import / params / busbars) + right pane (canvas + live SVG preview) + top bar (project switcher + history + exports) + history modal.
|
- `static/index.html` — busbar designer page; left sidebar (import / params / busbars) + right pane (canvas + live SVG preview) + top bar (project switcher + history + exports) + history modal.
|
||||||
|
- `static/holder.html` — hex-holder designer page; parameter form (left) + Three.js viewer (right).
|
||||||
|
- `static/js/holder-app.js`, `holder-viewer.js`, `holder.css` — UI for `/holder`. holder-viewer is an ES module (Three.js imported via importmap from jsdelivr).
|
||||||
- `static/js/app.js` — top-level controller; owns in-memory state, hooks auto-save + URL routing.
|
- `static/js/app.js` — top-level controller; owns in-memory state, hooks auto-save + URL routing.
|
||||||
- `static/js/api.js` — thin fetch-based REST client (Api.listProjects, getProject, ...).
|
- `static/js/api.js` — thin fetch-based REST client (Api.listProjects, getProject, ...).
|
||||||
- `static/js/importer.js` — paste-text parser + generator.
|
- `static/js/importer.js` — paste-text parser + generator.
|
||||||
|
|||||||
+3
-1
@@ -20,9 +20,11 @@ RUN pip install --user -r requirements.txt && \
|
|||||||
|
|
||||||
FROM python:3.12-slim AS runtime
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
# OCP needs these at runtime.
|
# OCP runtime libs + openscad (subprocess for /api/holder/render).
|
||||||
|
# openscad headless renders STL from .scad with parameter overrides via -D.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libgl1 libglu1-mesa libxrender1 libxext6 libsm6 libgomp1 \
|
libgl1 libglu1-mesa libxrender1 libxext6 libsm6 libgomp1 \
|
||||||
|
openscad xvfb \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& useradd --create-home --shell /usr/sbin/nologin app
|
&& useradd --create-home --shell /usr/sbin/nologin app
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
# Busbar Designer
|
# Busbar Designer + Hex Holder Designer
|
||||||
|
|
||||||
|
Web "combine" for cylindrical-cell battery pack production. Two pages:
|
||||||
|
|
||||||
|
| Page | Purpose |
|
||||||
|
|-------------|---------------------------------------------------------------------------|
|
||||||
|
| `/holder` | Parametric **hex cell holder** designer — adjust pack size / cell type, see 3D, download STL for printing. |
|
||||||
|
| `/` | **Busbar** designer — import cell coordinates, group into busbars (parallel + series), export STEP/DXF/SVG for laser cutting. |
|
||||||
|
|
||||||
|
The holder page has a **Design busbars →** button that auto-creates a busbar project pre-loaded with the exact cell coordinates from the configured holder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Web tool for designing nickel/copper **busbars** over cylindrical-cell battery packs (21700, 18650, ...) built with hex-shaped cell holders.
|
Web tool for designing nickel/copper **busbars** over cylindrical-cell battery packs (21700, 18650, ...) built with hex-shaped cell holders.
|
||||||
|
|
||||||
@@ -11,9 +22,10 @@ Workflow:
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Requires **Python 3.10+**. On Windows / PowerShell:
|
Requires **Python 3.10+** and **OpenSCAD** on the host (only needed for the `/holder` page; the busbar page works without it).
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
# Windows / PowerShell
|
||||||
cd busbar-designer
|
cd busbar-designer
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.\.venv\Scripts\Activate.ps1
|
.\.venv\Scripts\Activate.ps1
|
||||||
@@ -21,7 +33,9 @@ pip install -r requirements.txt
|
|||||||
python app.py
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open <http://localhost:5000> in your browser.
|
OpenSCAD install: Windows — `winget install OpenSCAD.OpenSCAD`; Linux — `apt install openscad`.
|
||||||
|
|
||||||
|
Then open <http://localhost:5000> for the busbar designer, or <http://localhost:5000/holder> for the hex holder designer.
|
||||||
|
|
||||||
> First-time install pulls **build123d** (~ 200 MB; bundles OpenCASCADE via OCP) and may take several minutes.
|
> First-time install pulls **build123d** (~ 200 MB; bundles OpenCASCADE via OCP) and may take several minutes.
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from flask import Flask, Response, jsonify, request, send_from_directory
|
|||||||
|
|
||||||
from busbar_export import WRITERS
|
from busbar_export import WRITERS
|
||||||
import storage
|
import storage
|
||||||
|
import holder
|
||||||
|
|
||||||
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
STATIC_DIR = os.path.join(APP_DIR, "static")
|
STATIC_DIR = os.path.join(APP_DIR, "static")
|
||||||
@@ -37,9 +38,51 @@ def index():
|
|||||||
return send_from_directory(STATIC_DIR, "index.html")
|
return send_from_directory(STATIC_DIR, "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/holder")
|
||||||
|
def holder_page():
|
||||||
|
return send_from_directory(STATIC_DIR, "holder.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok", "openscad": holder.openscad_available()})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hex holder designer (OpenSCAD-backed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@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()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/holder/render")
|
||||||
|
def holder_render():
|
||||||
|
"""Render STL for the supplied parameter overrides."""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
data = holder.render_stl(body.get("params", {}))
|
||||||
|
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"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/holder/cells")
|
||||||
|
def holder_cells():
|
||||||
|
"""Cell-center coordinates for the supplied parameters (no OpenSCAD needed)."""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
cells = holder.compute_cells(body.get("params", {}))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
return jsonify({"cells": cells, "count": len(cells)})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+2
-1
@@ -43,7 +43,8 @@ apt-get update -qq
|
|||||||
apt-get install -y -qq \
|
apt-get install -y -qq \
|
||||||
git ca-certificates curl \
|
git ca-certificates curl \
|
||||||
python3 python3-venv python3-pip \
|
python3 python3-venv python3-pip \
|
||||||
libgl1 libglu1-mesa libxrender1 libxext6 libsm6 libgomp1
|
libgl1 libglu1-mesa libxrender1 libxext6 libsm6 libgomp1 \
|
||||||
|
openscad xvfb
|
||||||
|
|
||||||
# ---- service user ----------------------------------------------------------
|
# ---- service user ----------------------------------------------------------
|
||||||
if ! id "$SVC_USER" >/dev/null 2>&1; then
|
if ! id "$SVC_USER" >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Generate hex cell holder STL via OpenSCAD subprocess.
|
||||||
|
|
||||||
|
We embed Addy's hex_cell.scad (in scad/) and shell out to `openscad` with
|
||||||
|
parameter overrides via `-D var=value`. STL bytes come back from a temp file.
|
||||||
|
|
||||||
|
Cell-center coordinates are derived in *Python* using the same formulas
|
||||||
|
the .scad file uses (see static/js/importer.js for the JS mirror). That avoids
|
||||||
|
a second OpenSCAD invocation just to echo coordinates and matches the math
|
||||||
|
exactly — see `hex_holder_geometry` memory for the formulas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Locations / config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
|
SCAD_FILE = APP_DIR / "scad" / "hex_cell.scad"
|
||||||
|
OPENSCAD_BIN = os.environ.get("OPENSCAD_BIN", "openscad")
|
||||||
|
RENDER_TIMEOUT = int(os.environ.get("OPENSCAD_TIMEOUT", "60"))
|
||||||
|
|
||||||
|
|
||||||
|
def openscad_available() -> bool:
|
||||||
|
return shutil.which(OPENSCAD_BIN) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parameter schema — exposed to the frontend for auto-form generation.
|
||||||
|
# Only the subset users typically touch; the .scad itself has many more knobs
|
||||||
|
# that fall back to script defaults.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Param:
|
||||||
|
name: str # SCAD variable name
|
||||||
|
label: str # UI label
|
||||||
|
kind: str # "number" | "select" | "bool"
|
||||||
|
default: Any
|
||||||
|
group: str = "main"
|
||||||
|
options: list[str] | None = None # for kind="select"
|
||||||
|
min: float | None = None # for kind="number"
|
||||||
|
max: float | None = None
|
||||||
|
step: float | None = None
|
||||||
|
help: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
PARAMS: list[Param] = [
|
||||||
|
# Pack geometry
|
||||||
|
Param("part", "Part", "select", "holder", group="part",
|
||||||
|
options=["holder", "cap", "box lid", "box bottom", "insulator"],
|
||||||
|
help="Which piece to generate."),
|
||||||
|
Param("pack_style", "Pack style", "select", "rect", group="part",
|
||||||
|
options=["rect", "para", "tria"],
|
||||||
|
help="Cell arrangement."),
|
||||||
|
Param("wire_style", "Wire style", "select", "strip", group="part",
|
||||||
|
options=["strip", "bus"],
|
||||||
|
help="strip = nickel strips between cells; bus = bus wires between rows."),
|
||||||
|
|
||||||
|
# Cell
|
||||||
|
Param("cell_dia", "Cell diameter (mm)", "number", 21.2, group="cell",
|
||||||
|
min=10, max=40, step=0.1, help="21.2 for 21700, 18.4 for 18650, 26.5 for 26650."),
|
||||||
|
Param("cell_height", "Cell height (mm)", "number", 70.0, group="cell",
|
||||||
|
min=30, max=200, step=1),
|
||||||
|
Param("wall", "Wall thickness (mm)", "number", 0.8, group="cell",
|
||||||
|
min=0.4, max=3.0, step=0.1,
|
||||||
|
help="Wall around one cell; spacing between cells is 2× this."),
|
||||||
|
|
||||||
|
# Pack size
|
||||||
|
Param("num_rows", "Rows", "number", 6, group="size", min=1, max=40, step=1),
|
||||||
|
Param("num_cols", "Columns", "number", 12, group="size", min=1, max=40, step=1),
|
||||||
|
|
||||||
|
# Holder
|
||||||
|
Param("holder_height", "Holder height (mm)", "number", 10.0, group="holder",
|
||||||
|
min=4, max=30, step=0.5),
|
||||||
|
Param("slot_height", "Slot height (mm)", "number", 3.0, group="holder",
|
||||||
|
min=0, max=10, step=0.5,
|
||||||
|
help="Height of all wire slots (set 0 for no slots)."),
|
||||||
|
Param("col_slot_width", "Column slot width (mm)", "number", 8.0, group="holder",
|
||||||
|
min=0, max=20, step=0.5),
|
||||||
|
Param("row_slot_width", "Row slot width (mm)", "number", 8.0, group="holder",
|
||||||
|
min=0, max=20, step=0.5),
|
||||||
|
Param("cell_top_overlap", "Cell top overlap (mm)","number", 3.0, group="holder",
|
||||||
|
min=0, max=10, step=0.5,
|
||||||
|
help="Opening dia = cell_dia − 2 × this. Welding-window size."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def default_params() -> dict[str, Any]:
|
||||||
|
return {p.name: p.default for p in PARAMS}
|
||||||
|
|
||||||
|
|
||||||
|
def schema_dict() -> list[dict]:
|
||||||
|
out = []
|
||||||
|
for p in PARAMS:
|
||||||
|
d = {"name": p.name, "label": p.label, "kind": p.kind,
|
||||||
|
"default": p.default, "group": p.group}
|
||||||
|
if p.options is not None: d["options"] = p.options
|
||||||
|
if p.min is not None: d["min"] = p.min
|
||||||
|
if p.max is not None: d["max"] = p.max
|
||||||
|
if p.step is not None: d["step"] = p.step
|
||||||
|
if p.help is not None: d["help"] = p.help
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cell-center computation (Python mirror of get_hex_center_points_*).
|
||||||
|
# Returns list of {id, x, y} in mm.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_COS30 = math.cos(math.radians(30))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_cells(params: dict) -> list[dict]:
|
||||||
|
cell_dia = float(params.get("cell_dia", 21.2))
|
||||||
|
wall = float(params.get("wall", 0.8))
|
||||||
|
rows = int(params.get("num_rows", 6))
|
||||||
|
cols = int(params.get("num_cols", 12))
|
||||||
|
style = str(params.get("pack_style", "rect"))
|
||||||
|
|
||||||
|
hex_w = cell_dia + 2 * wall
|
||||||
|
hex_pt = (hex_w / 2) / _COS30
|
||||||
|
row_y = lambda r: r * 1.5 * hex_pt
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
cid = 1
|
||||||
|
if style == "rect":
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
x = (0 if r % 2 == 0 else 0.5 * hex_w) + hex_w * c
|
||||||
|
out.append({"id": cid, "x": x, "y": row_y(r)})
|
||||||
|
cid += 1
|
||||||
|
elif style == "para":
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
out.append({"id": cid, "x": r * 0.5 * hex_w + hex_w * c, "y": row_y(r)})
|
||||||
|
cid += 1
|
||||||
|
elif style == "tria":
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(r + 1):
|
||||||
|
out.append({"id": cid, "x": r * 0.5 * hex_w - hex_w * c, "y": row_y(r)})
|
||||||
|
cid += 1
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown pack_style: {style!r}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OpenSCAD render
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _to_scad_literal(v: Any) -> str:
|
||||||
|
"""Convert a Python value to the literal text OpenSCAD expects on -D."""
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return "true" if v else "false"
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return repr(v)
|
||||||
|
if isinstance(v, str):
|
||||||
|
# OpenSCAD strings are double-quoted; escape via json.
|
||||||
|
return json.dumps(v)
|
||||||
|
raise TypeError(f"unsupported parameter type: {type(v).__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_params(params: dict) -> dict:
|
||||||
|
"""Drop None and unknown-name params; coerce types where obvious."""
|
||||||
|
known = {p.name: p for p in PARAMS}
|
||||||
|
out = {}
|
||||||
|
for k, v in (params or {}).items():
|
||||||
|
if k not in known or v is None:
|
||||||
|
continue
|
||||||
|
p = known[k]
|
||||||
|
if p.kind == "number":
|
||||||
|
v = float(v) if p.step and p.step != int(p.step) else int(float(v))
|
||||||
|
# keep ints for integer-step params
|
||||||
|
if isinstance(p.default, int) and float(v).is_integer():
|
||||||
|
v = int(v)
|
||||||
|
elif p.kind == "bool":
|
||||||
|
v = bool(v)
|
||||||
|
elif p.kind == "select":
|
||||||
|
v = str(v)
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def render_stl(params: dict) -> bytes:
|
||||||
|
"""Render the .scad with given parameter overrides; return STL bytes."""
|
||||||
|
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)]
|
||||||
|
for k, v in clean.items():
|
||||||
|
cmd += ["-D", f"{k}={_to_scad_literal(v)}"]
|
||||||
|
cmd.append(str(SCAD_FILE))
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, timeout=RENDER_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise RuntimeError(f"openscad timed out after {RENDER_TIMEOUT}s")
|
||||||
|
if r.returncode != 0:
|
||||||
|
err = (r.stderr or b"").decode(errors="replace").strip()
|
||||||
|
raise RuntimeError(f"openscad failed (exit {r.returncode}):\n{err[-800:]}")
|
||||||
|
if not out.exists() or out.stat().st_size == 0:
|
||||||
|
raise RuntimeError("openscad produced no STL (geometry empty?)")
|
||||||
|
return out.read_bytes()
|
||||||
+1497
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
/* Holder-designer specific styles; complements styles.css. */
|
||||||
|
|
||||||
|
.holder-left { width: 340px; }
|
||||||
|
|
||||||
|
.viewer3d {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0a0d12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right { position: relative; }
|
||||||
|
|
||||||
|
/* Parameter form */
|
||||||
|
.param-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-group {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-group-title {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-row label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-row input[type=number],
|
||||||
|
.param-row select {
|
||||||
|
width: 110px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-row .param-help {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Hex Holder Designer</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<link rel="stylesheet" href="holder.css" />
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.165.0/examples/jsm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>Hex Holder Designer</h1>
|
||||||
|
|
||||||
|
<nav class="topbar-nav">
|
||||||
|
<a href="/holder" class="active">Holder</a>
|
||||||
|
<a href="/">Busbars</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<span id="status" class="topbar-status">— cells</span>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="btn-download-stl" class="primary" title="Download STL of the current configuration">Download STL</button>
|
||||||
|
<button id="btn-download-scad" title="Download the OpenSCAD source with current parameters baked in">Download .scad</button>
|
||||||
|
<span class="sep"></span>
|
||||||
|
<button id="btn-to-busbar" class="primary" title="Open Busbar Designer with these cell coordinates pre-loaded">Design busbars →</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<aside class="left holder-left">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Parameters</h2>
|
||||||
|
<div id="param-form" class="param-form">
|
||||||
|
<div class="hint">Loading parameter schema…</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Status</h2>
|
||||||
|
<p class="hint" id="render-status">idle</p>
|
||||||
|
<p class="hint" id="render-time"></p>
|
||||||
|
<p class="hint" id="warning"></p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="right">
|
||||||
|
<div id="viewer3d" class="viewer3d"></div>
|
||||||
|
<div class="viewport-overlay">
|
||||||
|
<div id="hint-controls">left-drag: rotate · right-drag: pan · wheel: zoom</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script type="module" src="js/holder-viewer.js"></script>
|
||||||
|
<script type="module" src="js/holder-app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,6 +10,11 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1>Busbar Designer</h1>
|
<h1>Busbar Designer</h1>
|
||||||
|
|
||||||
|
<nav class="topbar-nav">
|
||||||
|
<a href="/holder">Holder</a>
|
||||||
|
<a href="/" class="active">Busbars</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="project-bar">
|
<div class="project-bar">
|
||||||
<select id="project-select" title="Open project">
|
<select id="project-select" title="Open project">
|
||||||
<option value="">— select project —</option>
|
<option value="">— select project —</option>
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
/* holder-app.js — controller for the Hex Holder Designer page.
|
||||||
|
*
|
||||||
|
* - Loads /api/holder/params for the form schema and builds inputs.
|
||||||
|
* - On any param change, debounces 400 ms then POSTs /api/holder/render and
|
||||||
|
* pushes the returned STL ArrayBuffer into the Three.js viewer.
|
||||||
|
* - "Download STL" re-uses the last rendered blob (no double trip).
|
||||||
|
* - "Design busbars →" computes cells via /api/holder/cells, creates a new
|
||||||
|
* busbar-designer project via /api/projects, redirects to /?p=<id>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
params: {}, // current values, name → value
|
||||||
|
schema: [], // [{name, label, kind, ...}]
|
||||||
|
lastBlob: null, // last rendered STL blob (for download)
|
||||||
|
renderTimer: null,
|
||||||
|
rendering: false,
|
||||||
|
rendererReady: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----- viewer init ----------------------------------------------------------
|
||||||
|
// HolderViewer is set up by holder-viewer.js (loaded as ES module) and
|
||||||
|
// attached to window. We wait briefly for it to be ready.
|
||||||
|
|
||||||
|
function _whenViewerReady(cb) {
|
||||||
|
if (window.HolderViewer) {
|
||||||
|
cb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tries = 0;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
tries++;
|
||||||
|
if (window.HolderViewer) {
|
||||||
|
clearInterval(t);
|
||||||
|
cb();
|
||||||
|
} else if (tries > 50) {
|
||||||
|
clearInterval(t);
|
||||||
|
console.error("HolderViewer never loaded");
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- form generation ------------------------------------------------------
|
||||||
|
|
||||||
|
const GROUP_ORDER = ["part", "cell", "size", "holder"];
|
||||||
|
const GROUP_LABELS = {
|
||||||
|
part: "Part / pack",
|
||||||
|
cell: "Cell",
|
||||||
|
size: "Pack size",
|
||||||
|
holder: "Holder",
|
||||||
|
};
|
||||||
|
|
||||||
|
function _renderForm(schema, defaults) {
|
||||||
|
const root = $("param-form");
|
||||||
|
root.innerHTML = "";
|
||||||
|
|
||||||
|
// Group params.
|
||||||
|
const byGroup = new Map();
|
||||||
|
for (const p of schema) {
|
||||||
|
if (!byGroup.has(p.group)) byGroup.set(p.group, []);
|
||||||
|
byGroup.get(p.group).push(p);
|
||||||
|
}
|
||||||
|
const groups = GROUP_ORDER.filter((g) => byGroup.has(g))
|
||||||
|
.concat([...byGroup.keys()].filter((g) => !GROUP_ORDER.includes(g)));
|
||||||
|
|
||||||
|
for (const g of groups) {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "param-group";
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
title.className = "param-group-title";
|
||||||
|
title.textContent = GROUP_LABELS[g] || g;
|
||||||
|
wrap.appendChild(title);
|
||||||
|
|
||||||
|
for (const p of byGroup.get(g)) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "param-row";
|
||||||
|
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.textContent = p.label;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
let inp;
|
||||||
|
if (p.kind === "select") {
|
||||||
|
inp = document.createElement("select");
|
||||||
|
for (const opt of p.options || []) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = opt; o.textContent = opt;
|
||||||
|
if (opt === state.params[p.name]) o.selected = true;
|
||||||
|
inp.appendChild(o);
|
||||||
|
}
|
||||||
|
} else if (p.kind === "bool") {
|
||||||
|
inp = document.createElement("input");
|
||||||
|
inp.type = "checkbox";
|
||||||
|
inp.checked = !!state.params[p.name];
|
||||||
|
} else {
|
||||||
|
inp = document.createElement("input");
|
||||||
|
inp.type = "number";
|
||||||
|
if (p.min != null) inp.min = p.min;
|
||||||
|
if (p.max != null) inp.max = p.max;
|
||||||
|
if (p.step != null) inp.step = p.step;
|
||||||
|
inp.value = state.params[p.name] ?? p.default;
|
||||||
|
}
|
||||||
|
inp.name = p.name;
|
||||||
|
inp.dataset.kind = p.kind;
|
||||||
|
inp.addEventListener("change", _onParamChange);
|
||||||
|
inp.addEventListener("input", _onParamChange);
|
||||||
|
row.appendChild(inp);
|
||||||
|
|
||||||
|
if (p.help) {
|
||||||
|
const h = document.createElement("div");
|
||||||
|
h.className = "param-help";
|
||||||
|
h.textContent = p.help;
|
||||||
|
row.appendChild(h);
|
||||||
|
}
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
root.appendChild(wrap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onParamChange(e) {
|
||||||
|
const el = e.target;
|
||||||
|
const k = el.name;
|
||||||
|
const kind = el.dataset.kind;
|
||||||
|
let v;
|
||||||
|
if (kind === "bool") v = el.checked;
|
||||||
|
else if (kind === "number") v = el.value === "" ? null : Number(el.value);
|
||||||
|
else v = el.value;
|
||||||
|
state.params[k] = v;
|
||||||
|
_updateStatus();
|
||||||
|
_scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- render orchestration -------------------------------------------------
|
||||||
|
|
||||||
|
function _scheduleRender() {
|
||||||
|
clearTimeout(state.renderTimer);
|
||||||
|
state.renderTimer = setTimeout(_doRender, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _doRender() {
|
||||||
|
if (state.rendering) {
|
||||||
|
// Re-schedule once current finishes.
|
||||||
|
state.renderTimer = setTimeout(_doRender, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.rendering = true;
|
||||||
|
$("render-status").textContent = "rendering…";
|
||||||
|
$("warning").textContent = "";
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/holder/render", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ params: state.params }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = res.statusText;
|
||||||
|
try { msg = (await res.json()).error || msg; } catch {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
state.lastBlob = await res.blob();
|
||||||
|
const buf = await state.lastBlob.arrayBuffer();
|
||||||
|
window.HolderViewer.loadSTL(buf);
|
||||||
|
const dt = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
$("render-status").textContent = `✓ rendered in ${dt}s · ${(state.lastBlob.size/1024).toFixed(0)} kB`;
|
||||||
|
$("render-time").textContent = "";
|
||||||
|
} catch (e) {
|
||||||
|
$("render-status").textContent = `✗ ${e.message}`;
|
||||||
|
if (/openscad/i.test(e.message)) {
|
||||||
|
$("warning").textContent =
|
||||||
|
"Make sure OpenSCAD is installed on the server (apt install openscad).";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.rendering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateStatus() {
|
||||||
|
// Quick cell count without hitting the server — uses the same formulas
|
||||||
|
// server-side but we cheap-out here for instant feedback.
|
||||||
|
const rows = +state.params.num_rows || 0;
|
||||||
|
const cols = +state.params.num_cols || 0;
|
||||||
|
const style = state.params.pack_style;
|
||||||
|
let n;
|
||||||
|
if (style === "tria") n = rows * (rows + 1) / 2;
|
||||||
|
else n = rows * cols;
|
||||||
|
$("status").textContent = `${n} cells`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- buttons --------------------------------------------------------------
|
||||||
|
|
||||||
|
$("btn-download-stl").addEventListener("click", () => {
|
||||||
|
if (!state.lastBlob) { alert("Nothing rendered yet."); return; }
|
||||||
|
const url = URL.createObjectURL(state.lastBlob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `hex_holder_${state.params.pack_style}_${state.params.num_rows}x${state.params.num_cols}.stl`;
|
||||||
|
document.body.appendChild(a); a.click();
|
||||||
|
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-download-scad").addEventListener("click", async () => {
|
||||||
|
// Reuse the bundled SCAD plus a header that pins current values, so the
|
||||||
|
// downloaded file reproduces the same model when opened in OpenSCAD.
|
||||||
|
const overrides = Object.entries(state.params)
|
||||||
|
.map(([k, v]) => `${k} = ${JSON.stringify(v)};`)
|
||||||
|
.join("\n");
|
||||||
|
// We don't have a backend endpoint for the .scad source yet; fetch it
|
||||||
|
// directly (deploy/install.sh puts it under /scad/hex_cell.scad — for the
|
||||||
|
// local Flask app it's not exposed under /static. So inline a banner that
|
||||||
|
// points the user to copy the params manually for now.)
|
||||||
|
const banner = `// Generated by Hex Holder Designer\n// Drop these overrides into hex_cell.scad (or pass via OpenSCAD -D):\n//\n${
|
||||||
|
overrides.split("\n").map((l) => "// " + l).join("\n")
|
||||||
|
}\n//\n// Or run: openscad -o out.stl ${
|
||||||
|
Object.entries(state.params).map(([k,v]) => `-D ${k}=${JSON.stringify(v)}`).join(" ")
|
||||||
|
} hex_cell.scad\n`;
|
||||||
|
const blob = new Blob([banner + "\n" + overrides + "\n"], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "hex_holder_params.scad";
|
||||||
|
document.body.appendChild(a); a.click();
|
||||||
|
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-to-busbar").addEventListener("click", async () => {
|
||||||
|
$("render-status").textContent = "exporting cells…";
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/holder/cells", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ params: state.params }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
|
||||||
|
const { cells } = await r.json();
|
||||||
|
if (!cells || !cells.length) throw new Error("no cells produced");
|
||||||
|
|
||||||
|
const partLabel = `${state.params.pack_style} ${state.params.num_rows}×${state.params.num_cols} (${state.params.cell_dia}mm)`;
|
||||||
|
const proj = await Api.createProject(`Holder · ${partLabel}`, {
|
||||||
|
params: {
|
||||||
|
cellDia: state.params.cell_dia,
|
||||||
|
openingDia: state.params.cell_dia - 2 * state.params.cell_top_overlap,
|
||||||
|
},
|
||||||
|
cells,
|
||||||
|
busbars: [],
|
||||||
|
activeBusbarId: null,
|
||||||
|
});
|
||||||
|
location.href = `/?p=${proj.id}`;
|
||||||
|
} catch (e) {
|
||||||
|
$("render-status").textContent = `✗ ${e.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- bootstrap ------------------------------------------------------------
|
||||||
|
|
||||||
|
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();
|
||||||
|
state.schema = schema;
|
||||||
|
state.params = { ...defaults };
|
||||||
|
_renderForm(schema, defaults);
|
||||||
|
_updateStatus();
|
||||||
|
} catch (e) {
|
||||||
|
$("param-form").innerHTML =
|
||||||
|
`<div class="step-preview-err">Couldn't load param schema: ${e.message}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_whenViewerReady(() => {
|
||||||
|
window.HolderViewer.init($("viewer3d"));
|
||||||
|
state.rendererReady = true;
|
||||||
|
_doRender(); // initial render with defaults
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/* holder-viewer.js — Three.js scene for STL preview.
|
||||||
|
*
|
||||||
|
* Exports a single global `HolderViewer` (UMD-ish, attached to window) so the
|
||||||
|
* non-module app script can use it. Set up via HolderViewer.init(canvasEl);
|
||||||
|
* load STL bytes via HolderViewer.loadSTL(arrayBuffer).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { STLLoader } from "three/addons/loaders/STLLoader.js";
|
||||||
|
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||||
|
|
||||||
|
const HolderViewer = (() => {
|
||||||
|
let scene, camera, renderer, controls;
|
||||||
|
let mesh = null;
|
||||||
|
let host = null;
|
||||||
|
|
||||||
|
function init(hostEl) {
|
||||||
|
host = hostEl;
|
||||||
|
const w = host.clientWidth || 800;
|
||||||
|
const h = host.clientHeight || 600;
|
||||||
|
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0a0d12);
|
||||||
|
|
||||||
|
camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 5000);
|
||||||
|
camera.position.set(120, 120, 120);
|
||||||
|
camera.up.set(0, 0, 1); // Z-up (CAD convention)
|
||||||
|
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
host.innerHTML = "";
|
||||||
|
host.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
controls.screenSpacePanning = true;
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||||
|
key.position.set(1, 1, 2).normalize();
|
||||||
|
scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0xaccfff, 0.35);
|
||||||
|
fill.position.set(-1, -1, 0.5).normalize();
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
// Build plate grid (10 mm minor, 50 mm major) at Z=0
|
||||||
|
const grid = new THREE.GridHelper(400, 40, 0x2a3140, 0x1f2530);
|
||||||
|
grid.rotateX(Math.PI / 2); // make it the XY plane (camera up is Z)
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
// Axes (small)
|
||||||
|
const axes = new THREE.AxesHelper(20);
|
||||||
|
scene.add(axes);
|
||||||
|
|
||||||
|
// Resize observer
|
||||||
|
new ResizeObserver(_onResize).observe(host);
|
||||||
|
|
||||||
|
_animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onResize() {
|
||||||
|
if (!host) return;
|
||||||
|
const w = host.clientWidth, h = host.clientHeight;
|
||||||
|
if (w === 0 || h === 0) return;
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _animate() {
|
||||||
|
requestAnimationFrame(_animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load STL bytes (ArrayBuffer) and replace any existing mesh. */
|
||||||
|
function loadSTL(buf) {
|
||||||
|
const loader = new STLLoader();
|
||||||
|
const geom = loader.parse(buf);
|
||||||
|
geom.computeVertexNormals();
|
||||||
|
// Centre the geometry on its bounding box and align bottom to Z=0.
|
||||||
|
geom.computeBoundingBox();
|
||||||
|
const bb = geom.boundingBox;
|
||||||
|
const cx = (bb.min.x + bb.max.x) / 2;
|
||||||
|
const cy = (bb.min.y + bb.max.y) / 2;
|
||||||
|
geom.translate(-cx, -cy, -bb.min.z);
|
||||||
|
|
||||||
|
if (mesh) {
|
||||||
|
scene.remove(mesh);
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
mesh.material.dispose();
|
||||||
|
}
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xf08a24,
|
||||||
|
metalness: 0.15,
|
||||||
|
roughness: 0.65,
|
||||||
|
flatShading: false,
|
||||||
|
});
|
||||||
|
mesh = new THREE.Mesh(geom, mat);
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
_fitCameraToMesh(geom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fitCameraToMesh(geom) {
|
||||||
|
const bb = geom.boundingBox;
|
||||||
|
const size = bb.getSize(new THREE.Vector3());
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const dist = maxDim * 1.6;
|
||||||
|
camera.position.set(dist * 0.8, -dist * 0.8, dist * 0.8);
|
||||||
|
controls.target.set(0, 0, size.z / 2);
|
||||||
|
camera.lookAt(controls.target);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (mesh) {
|
||||||
|
scene.remove(mesh);
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
mesh.material.dispose();
|
||||||
|
mesh = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, loadSTL, clear };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.HolderViewer = HolderViewer;
|
||||||
@@ -61,6 +61,27 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav a:hover { color: var(--text); }
|
||||||
|
.topbar-nav a.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.project-bar {
|
.project-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user