Initial commit: Busbar Designer

Web tool for designing nickel/copper busbars over cylindrical-cell battery
packs (21700, 18650) in hex holders. Flask + build123d backend exports
STEP/DXF/SVG; vanilla JS frontend with live preview, multi-project SQLite
persistence, snapshot history.

Deploy scripts in deploy/ (proxmox-lxc.sh, install.sh, update.sh).
This commit is contained in:
wenil
2026-05-24 18:59:50 +03:00
commit d8cb0dc06d
28 changed files with 4172 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Busbar Designer</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="topbar">
<h1>Busbar Designer</h1>
<div class="project-bar">
<select id="project-select" title="Open project">
<option value="">— select project —</option>
</select>
<input id="project-name" type="text" placeholder="Project name" />
<button id="btn-project-new" title="Create a new empty project">+ New</button>
<button id="btn-project-del" class="danger" title="Delete current project">×</button>
<button id="btn-save-now" class="primary" title="Save current state to server now (no waiting for auto-save)">Save</button>
<button id="btn-history" title="View snapshot history">History</button>
<span id="save-status" class="save-status"></span>
<span id="status" class="topbar-status">No cells loaded</span>
</div>
<div class="actions">
<button id="btn-save" title="Download project as JSON file">Save .json</button>
<button id="btn-load" title="Import project from JSON file">Load .json</button>
<input type="file" id="file-load" accept=".json" hidden />
<span class="sep"></span>
<button id="btn-export-step" class="primary">Export STEP</button>
<button id="btn-export-dxf">Export DXF</button>
<button id="btn-export-svg">Export SVG</button>
</div>
</header>
<main>
<aside class="left">
<section class="panel">
<h2>1. Cell import</h2>
<div class="tabs" id="import-tabs">
<button class="tab active" data-tab="paste">Paste</button>
<button class="tab" data-tab="csv">CSV</button>
<button class="tab" data-tab="json">JSON</button>
<button class="tab" data-tab="gen">Generator</button>
</div>
<div class="tab-body" data-tab-body="paste">
<p class="hint">Paste OpenSCAD ECHO lines (<code>ECHO: "Cell 1: x = …, y = …"</code>) or plain <code>id x y</code> lines.</p>
<textarea id="paste-text" rows="8" placeholder='ECHO: "Cell 1: x = 0, y = 0"&#10;ECHO: "Cell 2: x = 22.8, y = 0"'></textarea>
<button id="btn-import-paste">Import</button>
</div>
<div class="tab-body hidden" data-tab-body="csv">
<p class="hint">CSV with optional header: <code>index,x,y</code></p>
<textarea id="csv-text" rows="8" placeholder="1,0,0&#10;2,22.8,0"></textarea>
<button id="btn-import-csv">Import</button>
</div>
<div class="tab-body hidden" data-tab-body="json">
<p class="hint">JSON: <code>[{"id":1,"x":0,"y":0}, …]</code> or <code>[[x,y], …]</code></p>
<textarea id="json-text" rows="8" placeholder='[[0,0],[22.8,0]]'></textarea>
<button id="btn-import-json">Import</button>
</div>
<div class="tab-body hidden" data-tab-body="gen">
<p class="hint">Exact formulas from <code>hex_cell.scad</code>.</p>
<div class="grid">
<label>Cell dia (mm) <input type="number" id="gen-cell-dia" value="21.2" step="0.1"></label>
<label>Wall (mm) <input type="number" id="gen-wall" value="0.8" step="0.1"></label>
<label>Rows <input type="number" id="gen-rows" value="4" min="1"></label>
<label>Cols <input type="number" id="gen-cols" value="6" min="1"></label>
<label>Style
<select id="gen-style">
<option value="rect">rect</option>
<option value="para">para</option>
<option value="tria">tria</option>
</select>
</label>
</div>
<button id="btn-import-gen">Generate</button>
</div>
</section>
<section class="panel">
<h2>2. Cell &amp; busbar params</h2>
<div class="preset-row">
<select id="preset-select">
<option value="">— saved preset —</option>
</select>
<button id="btn-preset-apply" title="Apply selected preset">Apply</button>
<button id="btn-preset-save" class="primary" title="Save current params as new preset">Save as preset</button>
<button id="btn-preset-del" class="danger" title="Delete selected preset">×</button>
</div>
<div class="grid">
<label>Cell dia (mm) <input type="number" id="p-cell-dia" value="21.2" step="0.1"></label>
<label>Opening dia (mm) <input type="number" id="p-opening-dia" value="15.2" step="0.1"></label>
<label>Pad radius (mm) <input type="number" id="p-pad-radius" value="9.0" step="0.1" title="Disc radius over each cell (panel + wire)"></label>
<label>Strip width (mm) <input type="number" id="p-strip-width" value="6.0" step="0.1" title="Connector width between cells"></label>
<label>Hole shape
<select id="p-hole-shape">
<option value="cross" selected>cross (slit)</option>
<option value="circle">circle</option>
</select>
</label>
<label>Hole radius (mm) <input type="number" id="p-hole-radius" value="6.0" step="0.1" title="For cross: half-length of each arm; for circle: radius"></label>
<label>Slit width (mm) <input type="number" id="p-slit-width" value="1.0" step="0.1" title="Width of each cross arm"></label>
<label>Neighbor factor <input type="number" id="p-neighbor-factor" value="1.15" step="0.05" min="1.0" title="Bridge two cells if distance ≤ factor × shortest pair distance"></label>
<label class="checkbox"><input type="checkbox" id="p-extrude"> Extrude solid</label>
<label>Thickness (mm) <input type="number" id="p-thickness" value="0.2" step="0.05"></label>
</div>
</section>
<section class="panel">
<h2>3. Busbars</h2>
<div class="busbar-toolbar">
<button id="btn-new-busbar" class="primary">+ New busbar from selection</button>
<span class="sel-info" id="sel-info">0 selected</span>
</div>
<ul id="busbar-list" class="busbar-list"></ul>
<p class="hint">Click cell to select. Shift+click extend; Alt+click deselect. Right-mouse drag to pan; wheel to zoom.</p>
</section>
</aside>
<section class="right">
<div class="viewport-wrap">
<canvas id="viewport"></canvas>
<div class="viewport-overlay">
<div id="cursor-pos">x: — , y: —</div>
<div id="zoom-info">1.0 px/mm</div>
</div>
</div>
<div class="step-preview-wrap collapsed" id="step-preview-wrap">
<div class="step-preview-header">
<button id="step-preview-collapse" class="step-preview-collapse" title="Show / hide preview"></button>
<label class="checkbox">
<input type="checkbox" id="step-preview-toggle" checked>
Live STEP preview
</label>
<span id="step-preview-status" class="hint">idle</span>
</div>
<div class="step-preview" id="step-preview-content">
<div class="step-preview-empty">Create a busbar to see the exported geometry here.</div>
</div>
</div>
</section>
</main>
<!-- History modal -->
<div id="history-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Snapshot history</h3>
<button id="btn-history-close" class="modal-close">×</button>
</div>
<div class="modal-body">
<p class="hint">Each auto-save creates a snapshot of the prior state (max 20 per project). Restore rolls back; the current state is auto-snapshotted first.</p>
<ul id="history-list" class="history-list"></ul>
</div>
</div>
</div>
<script src="js/importer.js"></script>
<script src="js/groups.js"></script>
<script src="js/geometry.js"></script>
<script src="js/viewport.js"></script>
<script src="js/exporter.js"></script>
<script src="js/api.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
/* api.js — thin REST client over fetch(). Throws on non-2xx so callers can
* try/catch. Returns parsed JSON.
*/
const Api = (() => {
async function _req(method, url, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers["content-type"] = "application/json";
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).error || msg; } catch {}
const err = new Error(`${res.status} ${msg}`);
err.status = res.status;
throw err;
}
if (res.status === 204) return null;
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res.text();
}
return {
// Projects
listProjects: () => _req("GET", "/api/projects"),
getProject: (id) => _req("GET", `/api/projects/${id}`),
createProject: (name, data) => _req("POST", "/api/projects", { name, data }),
updateProject: (id, payload) => _req("PUT", `/api/projects/${id}`, payload),
deleteProject: (id) => _req("DELETE", `/api/projects/${id}`),
// Snapshots
listSnapshots: (projectId) => _req("GET", `/api/projects/${projectId}/snapshots`),
getSnapshot: (sid) => _req("GET", `/api/snapshots/${sid}`),
restoreSnapshot: (sid) => _req("POST", `/api/snapshots/${sid}/restore`),
// Presets
listPresets: () => _req("GET", "/api/presets"),
createPreset: (name, params) => _req("POST", "/api/presets", { name, params }),
updatePreset: (id, payload) => _req("PUT", `/api/presets/${id}`, payload),
deletePreset: (id) => _req("DELETE", `/api/presets/${id}`),
};
})();
+666
View File
@@ -0,0 +1,666 @@
/* app.js — top-level controller.
*
* Persistence model:
* - Each "project" is a row in SQLite holding the full editor state
* (cells + busbars + params + activeBusbarId).
* - Active project id lives in the URL as ?p=<id>; bookmarking / opening
* in another browser reloads the same state.
* - Any onStateChanged() fires Viewport.render(), schedulePreview() and
* scheduleAutoSave(). Auto-save is debounced 1500 ms; the first save in
* each 60 s window also writes a snapshot (history).
* - If the user edits before naming a project, one is auto-created with a
* timestamp name; URL is updated.
*
* Presets are separate from projects: just a named blob of params.
*/
(() => {
// ---- state ---------------------------------------------------------------
const state = {
cells: [],
busbars: [],
cellToBusbar: new Map(),
selection: new Set(),
activeBusbarId: null,
};
const params = {
cellDia: 21.2,
openingDia: 15.2,
stripWidth: 6.0,
padRadius: 9.0,
holeRadius: 6.0,
holeShape: "cross",
slitWidth: 1.0,
neighborFactor: 1.15,
extrude: false,
thickness: 0.2,
};
let currentProject = null; // { id, name } when one is open
let isSaving = false;
let pendingSave = false;
let isDirty = false; // unsaved changes since the last successful save
let autoSaveTimer = null;
let lastSnapshotAt = 0;
const SNAPSHOT_INTERVAL_MS = 60_000;
let stepPreviewEnabled = true;
let stepPreviewTimer = null;
let stepPreviewInFlight = null;
// ---- helpers -------------------------------------------------------------
const $ = (id) => document.getElementById(id);
const fmtMm = (v) => (Math.round(v * 100) / 100).toFixed(2);
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
function setSaveStatus(cls, text) {
const el = $("save-status");
if (!el) return;
el.className = "save-status " + (cls || "");
el.textContent = text || "—";
}
// ---- tabs ---------------------------------------------------------------
document.querySelectorAll(".tab").forEach((t) => {
t.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active"));
t.classList.add("active");
const target = t.dataset.tab;
document.querySelectorAll(".tab-body").forEach((b) => {
b.classList.toggle("hidden", b.dataset.tabBody !== target);
});
});
});
// ---- cell import --------------------------------------------------------
$("btn-import-paste").addEventListener("click", () => loadCells(Importer.parsePaste($("paste-text").value)));
$("btn-import-csv" ).addEventListener("click", () => loadCells(Importer.parseCSV ($("csv-text" ).value)));
$("btn-import-json" ).addEventListener("click", () => {
try { loadCells(Importer.parseJSON($("json-text").value)); }
catch (e) { alert(`JSON parse error: ${e.message}`); }
});
$("btn-import-gen").addEventListener("click", () => {
loadCells(Importer.generate({
cellDia: +$("gen-cell-dia").value,
wall: +$("gen-wall").value,
rows: +$("gen-rows").value,
cols: +$("gen-cols").value,
style: $("gen-style").value,
}));
});
// ---- param inputs -------------------------------------------------------
const paramInputs = [
["p-cell-dia", "cellDia"],
["p-opening-dia", "openingDia"],
["p-strip-width", "stripWidth"],
["p-pad-radius", "padRadius"],
["p-hole-radius", "holeRadius"],
["p-slit-width", "slitWidth"],
["p-neighbor-factor", "neighborFactor"],
["p-thickness", "thickness"],
];
for (const [id, key] of paramInputs) {
$(id).addEventListener("input", () => {
params[key] = +$(id).value;
onStateChanged();
});
}
$("p-hole-shape").addEventListener("change", () => {
params.holeShape = $("p-hole-shape").value;
onStateChanged();
});
$("p-extrude").addEventListener("change", () => {
params.extrude = $("p-extrude").checked;
schedulePreview();
scheduleAutoSave();
});
function _syncParamInputs() {
for (const [id, key] of paramInputs) $(id).value = params[key];
$("p-hole-shape").value = params.holeShape || "cross";
$("p-extrude").checked = !!params.extrude;
}
// ---- busbar create button -----------------------------------------------
$("btn-new-busbar").addEventListener("click", () => {
const ids = [...state.selection];
if (!ids.length) { alert("Select cells first."); return; }
const bb = Groups.create(state, ids);
state.activeBusbarId = bb.id;
state.selection.clear();
renderBusbarList();
updateSelInfo();
onStateChanged();
});
// ---- .json export / import (file-based; complements server storage) -----
$("btn-save").addEventListener("click", saveProjectAsFile);
$("btn-load").addEventListener("click", () => $("file-load").click());
$("file-load").addEventListener("change", (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
try { applyState(JSON.parse(r.result)); onStateChanged(); }
catch (err) { alert(err.message); }
};
r.readAsText(f);
e.target.value = "";
});
// ---- export buttons -----------------------------------------------------
$("btn-export-step").addEventListener("click", () => Exporter.exportFormat("step", state, params));
$("btn-export-dxf" ).addEventListener("click", () => Exporter.exportFormat("dxf", state, params));
$("btn-export-svg" ).addEventListener("click", () => Exporter.exportFormat("svg", state, params));
// ---- viewport init ------------------------------------------------------
Viewport.init($("viewport"), state, params, {
onCellClick: (cellId, mods) => {
if (mods.alt) state.selection.delete(cellId);
else if (mods.shift) state.selection.add(cellId);
else { state.selection.clear(); state.selection.add(cellId); }
updateSelInfo();
Viewport.render();
},
onCursorMove: (x, y) => {
$("cursor-pos").textContent = `x: ${fmtMm(x)} , y: ${fmtMm(y)}`;
},
onZoomChange: (s) => {
$("zoom-info").textContent = `${fmtMm(s)} px/mm`;
},
});
// ---- project bar --------------------------------------------------------
$("project-select").addEventListener("change", (e) => {
const id = +e.target.value;
if (!id) return;
openProject(id);
});
$("project-name").addEventListener("change", async (e) => {
if (!currentProject) return;
const name = e.target.value.trim();
if (!name) { e.target.value = currentProject.name; return; }
try {
await Api.updateProject(currentProject.id, { name });
currentProject.name = name;
await refreshProjectList();
setSaveStatus("saved", "renamed");
setTimeout(() => setSaveStatus("", "—"), 1500);
} catch (err) { alert(err.message); }
});
$("btn-project-new").addEventListener("click", async () => {
const name = prompt(
"New project name:",
`Project ${new Date().toLocaleDateString()}`
);
if (name === null) return;
try {
const r = await Api.createProject(name.trim() || "Untitled", _emptyProjectData());
await refreshProjectList();
openProject(r.id);
} catch (e) { alert(e.message); }
});
$("btn-project-del").addEventListener("click", async () => {
if (!currentProject) return;
if (!confirm(`Delete project "${currentProject.name}"? This cannot be undone.`)) return;
try {
await Api.deleteProject(currentProject.id);
currentProject = null;
$("project-name").value = "";
history.replaceState({}, "", location.pathname);
await refreshProjectList();
applyState(_emptyProjectData());
} catch (e) { alert(e.message); }
});
// ---- history modal ------------------------------------------------------
$("btn-history").addEventListener("click", showHistory);
$("btn-history-close").addEventListener("click", () => $("history-modal").classList.add("hidden"));
$("history-modal").addEventListener("click", (e) => {
if (e.target.id === "history-modal") $("history-modal").classList.add("hidden");
});
async function showHistory() {
if (!currentProject) { alert("Open a project first."); return; }
let snaps;
try { snaps = await Api.listSnapshots(currentProject.id); }
catch (e) { alert(e.message); return; }
const ul = $("history-list");
if (!snaps.length) {
ul.innerHTML = '<li class="hint">No snapshots yet.</li>';
} else {
ul.innerHTML = snaps.map((s) => `
<li class="history-item">
<span class="time">${escapeHtml(s.created_at)}</span>
<span class="note">${escapeHtml(s.note || "(auto)")}</span>
<button data-id="${s.id}" class="restore">Restore</button>
</li>`).join("");
ul.querySelectorAll(".restore").forEach((b) => {
b.addEventListener("click", async () => {
if (!confirm("Restore this snapshot? The current state will be saved to history first.")) return;
try {
await Api.restoreSnapshot(+b.dataset.id);
$("history-modal").classList.add("hidden");
await openProject(currentProject.id);
} catch (e) { alert(e.message); }
});
});
}
$("history-modal").classList.remove("hidden");
}
// ---- presets ------------------------------------------------------------
$("btn-preset-apply").addEventListener("click", async () => {
const id = +$("preset-select").value;
if (!id) return;
try {
const p = await Api.getPreset?.(id) || (await Api.listPresets()).find((x) => x.id === id);
if (!p) return;
Object.assign(params, p.params);
_syncParamInputs();
onStateChanged();
} catch (e) { alert(e.message); }
});
$("btn-preset-save").addEventListener("click", async () => {
const name = prompt("Preset name (e.g. '21700 0.2mm Ni'):");
if (!name) return;
try {
await Api.createPreset(name.trim(), { ...params });
await refreshPresetList();
} catch (e) {
alert(e.message);
}
});
$("btn-preset-del").addEventListener("click", async () => {
const id = +$("preset-select").value;
if (!id) return;
const name = $("preset-select").selectedOptions[0]?.text || "";
if (!confirm(`Delete preset "${name}"?`)) return;
try {
await Api.deletePreset(id);
await refreshPresetList();
} catch (e) { alert(e.message); }
});
// ---- top-level orchestration --------------------------------------------
function _emptyProjectData() {
return { params: { ...params }, cells: [], busbars: [], activeBusbarId: null };
}
function loadCells(cells) {
if (!cells || !cells.length) { alert("No cells parsed."); return; }
state.cells = cells;
state.busbars = [];
state.cellToBusbar = new Map();
state.selection = new Set();
Groups.reset();
updateStatus();
renderBusbarList();
updateSelInfo();
Viewport.fitToContent();
Viewport.render();
schedulePreview();
// Save NOW (skip debounce) — most "I refreshed and lost everything"
// reports happen when the user refreshes within the 1.5 s window.
clearTimeout(autoSaveTimer);
performAutoSave();
}
function applyState(data) {
if (data.params) Object.assign(params, data.params);
state.cells = (data.cells || []).map((c, i) => ({ id: c.id ?? i + 1, x: +c.x, y: +c.y }));
Groups.reset();
state.busbars = (data.busbars || []).map((b) => ({
id: b.id,
name: b.name,
color: b.color,
shape: b.shape || "panel",
cells: [...(b.cells || [])],
}));
state.cellToBusbar = new Map();
for (const b of state.busbars) for (const cid of b.cells) state.cellToBusbar.set(cid, b.id);
state.selection = new Set();
state.activeBusbarId = data.activeBusbarId ?? null;
_syncParamInputs();
updateStatus();
renderBusbarList();
updateSelInfo();
Viewport.fitToContent();
schedulePreview();
}
function onStateChanged() {
Viewport.render();
schedulePreview();
scheduleAutoSave();
}
function updateStatus() {
const el = $("status");
if (!el) return;
el.textContent = state.cells.length
? `${state.cells.length} cells loaded`
: "No cells loaded";
}
function updateSelInfo() {
const el = $("sel-info");
if (!el) return;
el.textContent = `${state.selection.size} selected`;
}
// ---- busbar list --------------------------------------------------------
function renderBusbarList() {
const ul = $("busbar-list");
ul.innerHTML = "";
for (const bb of state.busbars) {
const li = document.createElement("li");
li.className = "busbar-item" + (bb.id === state.activeBusbarId ? " active" : "");
const swatch = document.createElement("input");
swatch.type = "color";
swatch.className = "busbar-color";
swatch.value = bb.color;
swatch.addEventListener("input", (e) => {
Groups.recolor(state, bb.id, e.target.value);
onStateChanged();
});
const name = document.createElement("input");
name.className = "busbar-name";
name.value = bb.name;
name.addEventListener("change", (e) => {
Groups.rename(state, bb.id, e.target.value);
scheduleAutoSave();
});
const count = document.createElement("span");
count.className = "busbar-count";
count.textContent = `${bb.cells.length}p`;
const shapeSel = document.createElement("select");
shapeSel.className = "busbar-shape";
shapeSel.title = "Shape: panel = production plate; wire = thin strip";
for (const opt of ["panel", "wire"]) {
const o = document.createElement("option");
o.value = opt; o.textContent = opt;
if (bb.shape === opt) o.selected = true;
shapeSel.appendChild(o);
}
shapeSel.addEventListener("change", (e) => {
bb.shape = e.target.value;
onStateChanged();
});
const actions = document.createElement("div");
actions.className = "busbar-actions";
const addBtn = document.createElement("button");
addBtn.textContent = "+ sel";
addBtn.addEventListener("click", () => {
Groups.addCells(state, bb.id, [...state.selection]);
state.selection.clear();
renderBusbarList(); updateSelInfo(); onStateChanged();
});
const remBtn = document.createElement("button");
remBtn.textContent = " sel";
remBtn.addEventListener("click", () => {
Groups.removeCells(state, bb.id, [...state.selection]);
state.selection.clear();
renderBusbarList(); updateSelInfo(); onStateChanged();
});
const delBtn = document.createElement("button");
delBtn.className = "del";
delBtn.textContent = "×";
delBtn.addEventListener("click", () => {
if (!confirm(`Delete busbar "${bb.name}"?`)) return;
Groups.remove(state, bb.id);
renderBusbarList(); onStateChanged();
});
actions.appendChild(addBtn); actions.appendChild(remBtn); actions.appendChild(delBtn);
li.appendChild(swatch); li.appendChild(name); li.appendChild(count);
li.appendChild(shapeSel); li.appendChild(actions);
li.addEventListener("click", (e) => {
if (e.target !== li) return;
state.activeBusbarId = bb.id;
renderBusbarList();
});
ul.appendChild(li);
}
}
// ---- .json file save (download) -----------------------------------------
function saveProjectAsFile() {
const blob = new Blob([JSON.stringify(_serialize(), null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = (currentProject ? currentProject.name : "busbar-project") + ".json";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
}
function _serialize() {
return {
params,
cells: state.cells,
busbars: state.busbars,
activeBusbarId: state.activeBusbarId,
};
}
// ---- server: projects ---------------------------------------------------
async function refreshProjectList() {
try {
const list = await Api.listProjects();
const sel = $("project-select");
const keep = sel.value;
sel.innerHTML = '<option value="">— select project —</option>' +
list.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
if (currentProject) sel.value = String(currentProject.id);
else sel.value = keep;
} catch (e) {
console.error("refreshProjectList:", e);
}
}
async function openProject(id) {
try {
const p = await Api.getProject(id);
currentProject = { id: p.id, name: p.name };
lastSnapshotAt = 0;
$("project-name").value = p.name;
$("project-select").value = String(id);
history.replaceState({}, "", `?p=${id}`);
applyState(p.data || {});
setSaveStatus("saved", "loaded");
setTimeout(() => setSaveStatus("", "—"), 1500);
} catch (e) {
alert(`Open project failed: ${e.message}`);
}
}
async function ensureProject() {
if (currentProject) return;
const name = `Untitled ${new Date().toLocaleString()}`;
try {
const r = await Api.createProject(name, _serialize());
currentProject = { id: r.id, name: r.name };
$("project-name").value = r.name;
history.replaceState({}, "", `?p=${r.id}`);
await refreshProjectList();
} catch (e) {
console.error("ensureProject:", e);
}
}
// ---- auto-save ----------------------------------------------------------
function scheduleAutoSave() {
isDirty = true;
setSaveStatus("dirty", "● unsaved");
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(performAutoSave, 1500);
}
async function performAutoSave() {
await ensureProject();
if (!currentProject) return;
if (isSaving) { pendingSave = true; return; }
isSaving = true;
setSaveStatus("saving", "saving…");
try {
const now = Date.now();
const shouldSnapshot = now - lastSnapshotAt > SNAPSHOT_INTERVAL_MS;
await Api.updateProject(currentProject.id, {
data: _serialize(),
snapshot: shouldSnapshot,
note: shouldSnapshot ? "auto-save" : null,
});
if (shouldSnapshot) lastSnapshotAt = now;
isDirty = false;
setSaveStatus("saved", "✓ saved");
// Don't clear the indicator — keep "saved" visible until the next change.
} catch (e) {
setSaveStatus("error", `error: ${e.message}`);
} finally {
isSaving = false;
if (pendingSave) {
pendingSave = false;
scheduleAutoSave();
}
}
}
// ---- manual save + unload guard ----------------------------------------
$("btn-save-now").addEventListener("click", async () => {
clearTimeout(autoSaveTimer);
await performAutoSave();
});
window.addEventListener("beforeunload", (e) => {
if (isDirty) {
// Some browsers ignore custom messages but show a generic warning.
e.preventDefault();
e.returnValue = "You have unsaved changes. Leave anyway?";
return e.returnValue;
}
});
// ---- server: presets ----------------------------------------------------
async function refreshPresetList() {
try {
const list = await Api.listPresets();
const sel = $("preset-select");
const keep = sel.value;
sel.innerHTML = '<option value="">— saved preset —</option>' +
list.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
sel.value = keep;
} catch (e) {
console.error("refreshPresetList:", e);
}
}
// ---- live STEP preview --------------------------------------------------
$("step-preview-collapse").addEventListener("click", () => {
$("step-preview-wrap").classList.toggle("collapsed");
// After the pane resizes, canvas may need to refit — the ResizeObserver
// in viewport.js handles the backing-store, but tx/ty/scale are cached.
requestAnimationFrame(() => Viewport.fitToContent());
});
$("step-preview-toggle").addEventListener("change", (e) => {
stepPreviewEnabled = e.target.checked;
if (stepPreviewEnabled) updateStepPreview();
else {
$("step-preview-content").innerHTML =
'<div class="step-preview-empty">Live preview disabled.</div>';
$("step-preview-status").textContent = "off";
}
});
function schedulePreview() {
if (!stepPreviewEnabled) return;
clearTimeout(stepPreviewTimer);
stepPreviewTimer = setTimeout(updateStepPreview, 400);
}
async function updateStepPreview() {
const content = $("step-preview-content");
const status = $("step-preview-status");
if (!state.busbars.length) {
content.innerHTML =
'<div class="step-preview-empty">Create a busbar to see the exported geometry here.</div>';
status.textContent = "—";
return;
}
status.textContent = "loading…";
const reqId = Symbol("preview");
stepPreviewInFlight = reqId;
try {
const payload = Exporter.buildPayload(state, params);
const res = await fetch("/api/export/svg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (stepPreviewInFlight !== reqId) return;
if (!res.ok) {
let err = res.statusText;
try { err = (await res.json()).error || err; } catch {}
content.innerHTML = `<div class="step-preview-err">${escapeHtml(err)}</div>`;
status.textContent = "error";
return;
}
const svgText = await res.text();
if (stepPreviewInFlight !== reqId) return;
content.innerHTML = svgText;
const svgEl = content.querySelector("svg");
if (svgEl) {
svgEl.removeAttribute("width");
svgEl.removeAttribute("height");
}
status.textContent = `ok (${Math.round(svgText.length / 1024)} kB)`;
} catch (e) {
content.innerHTML = `<div class="step-preview-err">${escapeHtml(e.message)}</div>`;
status.textContent = "error";
}
}
// ---- init ---------------------------------------------------------------
async function init() {
await Promise.all([refreshProjectList(), refreshPresetList()]);
// URL routing: ?p=<id> opens that project.
const urlPid = new URLSearchParams(location.search).get("p");
if (urlPid && /^\d+$/.test(urlPid)) {
try { await openProject(+urlPid); }
catch (e) {
// Project doesn't exist anymore — strip query and show empty.
history.replaceState({}, "", location.pathname);
currentProject = null;
updateStatus();
renderBusbarList();
updateSelInfo();
}
} else {
updateStatus();
renderBusbarList();
updateSelInfo();
}
}
init();
})();
+60
View File
@@ -0,0 +1,60 @@
/* exporter.js — collect busbars into the backend payload and download file. */
const Exporter = (() => {
function buildPayload(state, params) {
const cellsById = new Map(state.cells.map((c) => [c.id, c]));
const busbars = state.busbars.map((bb) => ({
name: bb.name,
color: bb.color,
shape: bb.shape || "panel",
strip_width: params.stripWidth,
pad_radius: params.padRadius,
hole_radius: params.holeRadius,
hole_shape: params.holeShape || "cross",
slit_width: params.slitWidth,
neighbor_factor: params.neighborFactor,
cells: bb.cells
.map((cid) => {
const c = cellsById.get(cid);
return c ? { id: c.id, x: c.x, y: c.y } : null;
})
.filter(Boolean),
}));
return {
units: "mm",
extrude: !!params.extrude,
thickness: params.thickness,
busbars,
};
}
async function exportFormat(fmt, state, params) {
if (!state.busbars.length) {
alert("Create at least one busbar before exporting.");
return;
}
const payload = buildPayload(state, params);
const res = await fetch(`/api/export/${fmt}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
let msg;
try { msg = (await res.json()).error; } catch { msg = await res.text(); }
alert(`Export failed: ${msg}`);
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `busbars.${fmt}`;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
}
return { exportFormat, buildPayload };
})();
+144
View File
@@ -0,0 +1,144 @@
/* geometry.js — frontend-side preview of busbar shapes.
*
* Mirrors busbar_export.py exactly so the canvas preview is what the STEP
* will contain.
*
* panel = union of pad discs at every cell + stadium bridges between every
* pair of cells that are neighbors (distance ≤ neighborFactor ×
* min_pair_distance). Concave selections (L/U/T/...) hug their cells.
*
* wire = polyline strip of strip_width with pad discs at each cell.
*
* Welding windows (busbarHolesPath) are either:
* - cross = two perpendicular slits of length 2·hole_radius, width slit_width
* - circle = single disc of radius hole_radius
*/
const Geometry = (() => {
function neighborEdges(pts, factor) {
const n = pts.length;
if (n < 2) return [];
let minD = Infinity;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = pts[j][0] - pts[i][0], dy = pts[j][1] - pts[i][1];
const d = Math.hypot(dx, dy);
if (d > 1e-9 && d < minD) minD = d;
}
}
if (!isFinite(minD)) return [];
const thr = minD * factor;
const out = [];
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = pts[j][0] - pts[i][0], dy = pts[j][1] - pts[i][1];
const d = Math.hypot(dx, dy);
if (d > 1e-9 && d <= thr) out.push([i, j]);
}
}
return out;
}
function busbarPath(busbar, cellsById, params) {
return (busbar.shape === "wire")
? wirePath(busbar, cellsById, params)
: panelPath(busbar, cellsById, params);
}
/** Welding windows path. Caller fills with destination-out to punch. */
function busbarHolesPath(busbar, cellsById, params) {
const path = new Path2D();
const cells = busbar.cells.map((cid) => cellsById.get(cid)).filter(Boolean);
if (params.holeShape === "circle") {
for (const c of cells) {
path.moveTo(c.x + params.holeRadius, c.y);
path.arc(c.x, c.y, params.holeRadius, 0, Math.PI * 2);
}
} else {
// cross
const halfW = Math.max(0.05, params.slitWidth / 2);
const halfL = params.holeRadius;
for (const c of cells) {
_addRectXY(path, c.x, c.y, 2 * halfL, 2 * halfW); // horizontal arm
_addRectXY(path, c.x, c.y, 2 * halfW, 2 * halfL); // vertical arm
}
}
return path;
}
function wirePath(busbar, cellsById, params) {
const path = new Path2D();
const cells = busbar.cells.map((cid) => cellsById.get(cid)).filter(Boolean);
for (const c of cells) {
path.moveTo(c.x + params.padRadius, c.y);
path.arc(c.x, c.y, params.padRadius, 0, Math.PI * 2);
}
for (let i = 0; i < cells.length - 1; i++) {
_addRect(path, cells[i], cells[i + 1], params.stripWidth);
}
return path;
}
/** Panel = disc at every cell + stadium between every neighbor pair. */
function panelPath(busbar, cellsById, params) {
const path = new Path2D();
const cells = busbar.cells.map((cid) => cellsById.get(cid)).filter(Boolean);
if (!cells.length) return path;
// Discs.
for (const c of cells) {
path.moveTo(c.x + params.padRadius, c.y);
path.arc(c.x, c.y, params.padRadius, 0, Math.PI * 2);
}
if (cells.length < 2) return path;
// Neighbor bridges — narrow connector (strip_width), not 2*pad_radius —
// this gives the dog-bone shape and a real gap to neighboring busbars.
const pts = cells.map((c) => [c.x, c.y]);
const edges = neighborEdges(pts, params.neighborFactor || 1.15);
for (const [i, j] of edges) {
_addRect(path,
{ x: pts[i][0], y: pts[i][1] },
{ x: pts[j][0], y: pts[j][1] },
params.stripWidth);
}
return path;
}
/** Rectangle from a to b of given width, as a closed Path2D sub-path.
*
* Vertex order matches Path2D.arc() winding so all sub-paths in the busbar
* body wind the SAME direction. With ctx.fill(path, "nonzero") same-winding
* subpaths union (no fill cancellation in overlap regions). If the winding
* differs from arc(), overlap regions sum to 0 and appear as holes — the
* 'tangled black gaps inside the busbar' bug.
*/
function _addRect(path, a, b, width) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) return;
const ux = dx / len, uy = dy / len;
const px = -uy, py = ux;
const hw = width / 2;
// Order: above-A → below-A → below-B → above-B → close.
path.moveTo(a.x + px * hw, a.y + py * hw);
path.lineTo(a.x - px * hw, a.y - py * hw);
path.lineTo(b.x - px * hw, b.y - py * hw);
path.lineTo(b.x + px * hw, b.y + py * hw);
path.closePath();
}
/** Axis-aligned rectangle centered at (cx, cy). */
function _addRectXY(path, cx, cy, w, h) {
const hw = w / 2, hh = h / 2;
path.moveTo(cx - hw, cy - hh);
path.lineTo(cx + hw, cy - hh);
path.lineTo(cx + hw, cy + hh);
path.lineTo(cx - hw, cy + hh);
path.closePath();
}
return { busbarPath, busbarHolesPath, neighborEdges, panelPath, wirePath };
})();
+83
View File
@@ -0,0 +1,83 @@
/* groups.js — busbar (parallel cell group) CRUD.
*
* A Busbar is { id, name, color, cells: [cellId, …] }.
* Cell `assigned` lookup is maintained so the canvas can render colors.
*/
const Groups = (() => {
// Friendly hue palette — rotates as new busbars are added.
const COLORS = [
"#f08a24", "#4fa3ff", "#3ecf8e", "#e85aad",
"#a78bfa", "#f5d142", "#52d6c6", "#ff6b6b",
"#7cb342", "#ba68c8", "#26c6da", "#ffa726",
];
let nextId = 1;
let nextNameIdx = 1;
function create(state, cellIds) {
if (!cellIds || cellIds.length === 0) return null;
const id = nextId++;
const bb = {
id,
name: `P${nextNameIdx++}`,
color: COLORS[(id - 1) % COLORS.length],
shape: "panel", // "panel" (default, production plate) | "wire"
cells: [...cellIds],
};
state.busbars.push(bb);
_reassign(state);
return bb;
}
function remove(state, busbarId) {
state.busbars = state.busbars.filter((b) => b.id !== busbarId);
_reassign(state);
}
function rename(state, busbarId, name) {
const bb = state.busbars.find((b) => b.id === busbarId);
if (bb) bb.name = name;
}
function recolor(state, busbarId, color) {
const bb = state.busbars.find((b) => b.id === busbarId);
if (bb) bb.color = color;
}
function addCells(state, busbarId, cellIds) {
const bb = state.busbars.find((b) => b.id === busbarId);
if (!bb) return;
const set = new Set(bb.cells);
for (const id of cellIds) {
if (!set.has(id)) { bb.cells.push(id); set.add(id); }
}
_reassign(state);
}
function removeCells(state, busbarId, cellIds) {
const bb = state.busbars.find((b) => b.id === busbarId);
if (!bb) return;
const rm = new Set(cellIds);
bb.cells = bb.cells.filter((id) => !rm.has(id));
_reassign(state);
}
function _reassign(state) {
state.cellToBusbar = new Map();
for (const bb of state.busbars) {
for (const cid of bb.cells) state.cellToBusbar.set(cid, bb.id);
}
}
function findByCell(state, cellId) {
return state.cellToBusbar.get(cellId);
}
function reset() {
nextId = 1;
nextNameIdx = 1;
}
return { create, remove, rename, recolor, addCells, removeCells, findByCell, reset };
})();
+111
View File
@@ -0,0 +1,111 @@
/* importer.js — parse OpenSCAD ECHO / CSV / JSON / parametric generator.
*
* All parsers return an array of {id, x, y} in millimetres.
* Generator formulas are 1:1 mirrors of `get_hex_center_points_*` from
* Addy's hex_cell.scad (see CLAUDE.md for the cheat sheet).
*/
const Importer = (() => {
const COS30 = Math.cos(Math.PI / 6);
/** Parse OpenSCAD ECHO lines or loose `Cell N: x = …, y = …`. */
function parsePaste(text) {
const out = [];
const re = /Cell\s*(\d+)\s*:\s*x\s*=\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*,\s*y\s*=\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/g;
let m;
while ((m = re.exec(text)) !== null) {
out.push({ id: parseInt(m[1], 10), x: parseFloat(m[2]), y: parseFloat(m[3]) });
}
if (out.length) return out;
// Fallback: lines of form `index x y` or `index, x, y` (any separator).
const lines = text.split(/\r?\n/);
let auto = 1;
for (const line of lines) {
const nums = line.match(/-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?/g);
if (!nums) continue;
if (nums.length >= 3) {
out.push({ id: parseInt(nums[0], 10), x: parseFloat(nums[1]), y: parseFloat(nums[2]) });
} else if (nums.length === 2) {
out.push({ id: auto++, x: parseFloat(nums[0]), y: parseFloat(nums[1]) });
}
}
return out;
}
/** CSV with optional header. Columns: index,x,y OR x,y. */
function parseCSV(text) {
const out = [];
const lines = text.trim().split(/\r?\n/).filter(Boolean);
let auto = 1;
for (const raw of lines) {
const cols = raw.split(/[,;\t]/).map((s) => s.trim());
// Skip header rows that don't parse as numbers.
if (cols.length >= 2 && isNaN(parseFloat(cols[0])) && isNaN(parseFloat(cols[1]))) continue;
if (cols.length >= 3) {
const id = parseInt(cols[0], 10);
const x = parseFloat(cols[1]);
const y = parseFloat(cols[2]);
if (!isNaN(x) && !isNaN(y)) out.push({ id: isNaN(id) ? auto++ : id, x, y });
} else if (cols.length === 2) {
const x = parseFloat(cols[0]);
const y = parseFloat(cols[1]);
if (!isNaN(x) && !isNaN(y)) out.push({ id: auto++, x, y });
}
}
return out;
}
/** JSON: array of objects {id,x,y} OR array of [x,y]. */
function parseJSON(text) {
const j = JSON.parse(text);
if (!Array.isArray(j)) throw new Error("JSON must be a top-level array");
return j.map((item, i) => {
if (Array.isArray(item)) {
return { id: i + 1, x: +item[0], y: +item[1] };
}
return {
id: item.id != null ? +item.id : i + 1,
x: +item.x,
y: +item.y,
};
});
}
/** Generator — mirrors get_hex_center_points_{rect,para,tria} from hex_cell.scad. */
function generate({ cellDia, wall, rows, cols, style }) {
const hex_w = cellDia + 2 * wall;
const hex_pt = (hex_w / 2) / COS30;
const rowY = (r) => r * 1.5 * hex_pt;
const out = [];
let id = 1;
if (style === "rect") {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = (r % 2 === 0) ? hex_w * c : 0.5 * hex_w + hex_w * c;
out.push({ id: id++, x, y: rowY(r) });
}
}
} else if (style === "para") {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = r * 0.5 * hex_w + hex_w * c;
out.push({ id: id++, x, y: rowY(r) });
}
}
} else if (style === "tria") {
for (let r = 0; r < rows; r++) {
for (let c = 0; c <= r; c++) {
const x = r * 0.5 * hex_w - hex_w * c;
out.push({ id: id++, x, y: rowY(r) });
}
}
} else {
throw new Error(`unknown style: ${style}`);
}
return out;
}
return { parsePaste, parseCSV, parseJSON, generate };
})();
+320
View File
@@ -0,0 +1,320 @@
/* viewport.js — 2D canvas viewer with real mm scale, pan, zoom, cell picking.
*
* Coordinates in app state are millimetres; we keep a pan (tx, ty in px) and a
* scale (px-per-mm). Y axis is flipped on render so +Y points up like CAD.
*/
const Viewport = (() => {
let canvas, ctx, state, params;
let scale = 4; // px per mm
let tx = 0, ty = 0; // pan offset, in pixels (added after scaling)
let isPanning = false;
let panStart = null;
let cellHoverId = null;
// Callbacks set by app.js.
let onCellClick = () => {};
let onCursorMove = () => {};
let onZoomChange = () => {};
function init(canvasEl, stateRef, paramsRef, handlers) {
canvas = canvasEl;
ctx = canvas.getContext("2d");
state = stateRef;
params = paramsRef;
onCellClick = handlers.onCellClick || onCellClick;
onCursorMove = handlers.onCursorMove || onCursorMove;
onZoomChange = handlers.onZoomChange || onZoomChange;
window.addEventListener("resize", _resize);
// Catch any layout change (flex re-flow, sidebar toggle, etc.) — the
// canvas backing-store must match its CSS size or content draws blurry /
// mis-positioned ('cells off-screen' bug).
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(_resize).observe(canvas);
}
canvas.addEventListener("mousedown", _onMouseDown);
canvas.addEventListener("mousemove", _onMouseMove);
canvas.addEventListener("mouseup", _onMouseUp);
canvas.addEventListener("mouseleave", _onMouseUp);
canvas.addEventListener("wheel", _onWheel, { passive: false });
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
_resize();
}
function _resize() {
const dpr = window.devicePixelRatio || 1;
const r = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(r.width * dpr));
canvas.height = Math.max(1, Math.round(r.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // user space = CSS pixels
render();
}
function fitToContent() {
// Defer one frame so any pending layout (flex re-flow when busbar list
// grows, preview pane appears, etc.) is computed before we measure
// canvas dimensions. Without this, clientWidth/Height can be 0 → cells
// end up off-screen.
requestAnimationFrame(_fitToContentNow);
}
function _fitToContentNow() {
_resize();
if (!state.cells || state.cells.length === 0) {
tx = canvas.clientWidth / 2;
ty = canvas.clientHeight / 2;
scale = 4;
onZoomChange(scale);
render();
return;
}
const cellRadius = (params.cellDia || 21.2) / 2;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const c of state.cells) {
if (c.x < minX) minX = c.x; if (c.x > maxX) maxX = c.x;
if (c.y < minY) minY = c.y; if (c.y > maxY) maxY = c.y;
}
minX -= cellRadius; maxX += cellRadius;
minY -= cellRadius; maxY += cellRadius;
const w = maxX - minX, h = maxY - minY;
const margin = 40;
const sx = (canvas.clientWidth - 2 * margin) / w;
const sy = (canvas.clientHeight - 2 * margin) / h;
scale = Math.max(0.5, Math.min(40, Math.min(sx, sy)));
// World point at canvas center should be (minX+w/2, minY+h/2).
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
tx = canvas.clientWidth / 2 - cx * scale;
ty = canvas.clientHeight / 2 + cy * scale; // +cy*scale because Y is flipped
onZoomChange(scale);
render();
}
// World (mm) → screen (px)
function w2s(x, y) {
return { x: x * scale + tx, y: -y * scale + ty };
}
// Screen → world
function s2w(px, py) {
return { x: (px - tx) / scale, y: -(py - ty) / scale };
}
function render() {
if (!ctx) return;
const W = canvas.clientWidth, H = canvas.clientHeight;
ctx.clearRect(0, 0, W, H);
_drawGrid(W, H);
_drawCells();
_drawBusbars();
_drawCellLabels(); // labels on top so the cross-slit punch doesn't eat them
_drawSelection();
}
function _drawGrid(W, H) {
// Minor grid every 5 mm, major every 50 mm.
const minor = 5, major = 50;
const tl = s2w(0, 0), br = s2w(W, H);
const xMin = Math.floor(Math.min(tl.x, br.x) / minor) * minor;
const xMax = Math.ceil (Math.max(tl.x, br.x) / minor) * minor;
const yMin = Math.floor(Math.min(tl.y, br.y) / minor) * minor;
const yMax = Math.ceil (Math.max(tl.y, br.y) / minor) * minor;
ctx.lineWidth = 1;
for (let x = xMin; x <= xMax; x += minor) {
const p = w2s(x, 0);
ctx.strokeStyle = (Math.abs(x % major) < 1e-6) ? "#2a3140" : "#1f2530";
ctx.beginPath();
ctx.moveTo(p.x, 0); ctx.lineTo(p.x, H); ctx.stroke();
}
for (let y = yMin; y <= yMax; y += minor) {
const p = w2s(0, y);
ctx.strokeStyle = (Math.abs(y % major) < 1e-6) ? "#2a3140" : "#1f2530";
ctx.beginPath();
ctx.moveTo(0, p.y); ctx.lineTo(W, p.y); ctx.stroke();
}
// Origin marker.
const o = w2s(0, 0);
ctx.strokeStyle = "#ff6b6b";
ctx.beginPath();
ctx.moveTo(o.x - 8, o.y); ctx.lineTo(o.x + 8, o.y);
ctx.moveTo(o.x, o.y - 8); ctx.lineTo(o.x, o.y + 8);
ctx.stroke();
}
function _drawCells() {
const r = (params.cellDia || 21.2) / 2;
const o = (params.openingDia || 15.2) / 2;
const hexW = (params.cellDia || 21.2) + 2 * 0.8;
const hexPt = hexW / 2 / Math.cos(Math.PI / 6);
ctx.lineWidth = 1;
// For cells assigned to a busbar we draw NOTHING here — the busbar layer
// owns those pixels entirely (fill + cross slit + label). Drawing hex /
// circle outlines under the busbar muddies the preview.
for (const c of state.cells) {
if (Groups.findByCell(state, c.id) != null) continue;
const p = w2s(c.x, c.y);
const rPx = r * scale;
const oPx = o * scale;
// Hex outline.
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = Math.PI / 2 + i * Math.PI / 3;
const hx = p.x + hexPt * scale * Math.cos(a);
const hy = p.y - hexPt * scale * Math.sin(a);
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
}
ctx.closePath();
ctx.strokeStyle = "#262d3a";
ctx.stroke();
// Cell circle + opening hint (so the user sees the welding window).
ctx.beginPath();
ctx.arc(p.x, p.y, rPx, 0, Math.PI * 2);
ctx.fillStyle = _withAlpha("#4a5365", 0.18);
ctx.fill();
ctx.strokeStyle = "#4a5365";
ctx.stroke();
ctx.fillStyle = _withAlpha("#4a5365", 0.35);
ctx.beginPath();
ctx.arc(p.x, p.y, oPx, 0, Math.PI * 2);
ctx.fill();
}
}
function _drawCellLabels() {
if (scale <= 2) return;
ctx.font = `${Math.max(9, Math.min(14, scale * 1.5))}px -apple-system, system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (const c of state.cells) {
const p = w2s(c.x, c.y);
const inBusbar = Groups.findByCell(state, c.id) != null;
ctx.fillStyle = inBusbar ? "#1a1a1a" : "#cdd5e0";
ctx.fillText(String(c.id), p.x, p.y);
}
}
function _drawBusbars() {
if (!state.busbars.length) return;
const cellsById = new Map(state.cells.map((c) => [c.id, c]));
const dpr = window.devicePixelRatio || 1;
ctx.save();
// Set transform so we can draw busbar Path2D in world (mm) coords.
// screen.x = scale*world.x + tx ; screen.y = -scale*world.y + ty
ctx.setTransform(dpr * scale, 0, 0, -dpr * scale, dpr * tx, dpr * ty);
for (const bb of state.busbars) {
const params2 = {
padRadius: params.padRadius,
holeRadius: params.holeRadius,
stripWidth: params.stripWidth,
};
const body = Geometry.busbarPath(bb, cellsById, params2);
const holes = Geometry.busbarHolesPath(bb, cellsById, params2);
// Body: union semantics — fill nonzero so overlapping rectangles/discs
// don't carve evenodd holes. Higher opacity than v1 so the panel reads
// as a single object, not a translucent veil.
ctx.fillStyle = _withAlpha(bb.color, 0.75);
ctx.fill(body, "nonzero");
// Holes: punch via destination-out so the dark canvas shows through.
ctx.save();
ctx.globalCompositeOperation = "destination-out";
ctx.fill(holes, "nonzero");
ctx.restore();
}
ctx.restore();
}
function _drawSelection() {
if (!state.selection.size) return;
const r = ((params.cellDia || 21.2) / 2 + 1.5);
ctx.lineWidth = 2;
ctx.strokeStyle = "#f08a24";
for (const id of state.selection) {
const c = state.cells.find((cc) => cc.id === id);
if (!c) continue;
const p = w2s(c.x, c.y);
ctx.beginPath();
ctx.arc(p.x, p.y, r * scale, 0, Math.PI * 2);
ctx.stroke();
}
}
function _withAlpha(hex, a) {
const s = hex.replace("#", "");
const r = parseInt(s.slice(0, 2), 16);
const g = parseInt(s.slice(2, 4), 16);
const b = parseInt(s.slice(4, 6), 16);
return `rgba(${r},${g},${b},${a})`;
}
function _pickCell(px, py) {
const r = (params.cellDia || 21.2) / 2;
let best = null, bestDist = Infinity;
for (const c of state.cells) {
const p = w2s(c.x, c.y);
const d = Math.hypot(p.x - px, p.y - py);
if (d < r * scale && d < bestDist) { best = c; bestDist = d; }
}
return best;
}
function _onMouseDown(e) {
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left, py = e.clientY - rect.top;
if (e.button === 2 || e.button === 1) {
isPanning = true;
panStart = { px, py, tx, ty };
canvas.style.cursor = "grabbing";
return;
}
if (e.button === 0) {
const c = _pickCell(px, py);
if (c) onCellClick(c.id, { shift: e.shiftKey, alt: e.altKey, ctrl: e.ctrlKey });
}
}
function _onMouseMove(e) {
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left, py = e.clientY - rect.top;
if (isPanning && panStart) {
tx = panStart.tx + (px - panStart.px);
ty = panStart.ty + (py - panStart.py);
render();
return;
}
const w = s2w(px, py);
onCursorMove(w.x, w.y);
}
function _onMouseUp() {
isPanning = false;
panStart = null;
canvas.style.cursor = "crosshair";
}
function _onWheel(e) {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left, py = e.clientY - rect.top;
const pre = s2w(px, py);
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
scale = Math.max(0.5, Math.min(80, scale * factor));
// Keep cursor mm-point stationary.
tx = px - pre.x * scale;
ty = py + pre.y * scale;
onZoomChange(scale);
render();
}
return { init, render, fitToContent };
})();
+532
View File
@@ -0,0 +1,532 @@
:root {
--bg: #0f1115;
--panel: #161a22;
--panel-2: #1e242f;
--border: #2a313d;
--text: #e4e7ec;
--muted: #8a93a3;
--accent: #f08a24;
--accent-2: #4fa3ff;
--danger: #d94a4a;
--grid: #1f2530;
--grid-major: #2a3140;
--cell: #4a5365;
--cell-sel: #f08a24;
--cell-busbar: #4fa3ff;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden; /* never let the page itself scroll — flex children handle their own overflow */
}
/* ---- topbar ---- */
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.topbar h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.02em;
}
.topbar .status {
color: var(--muted);
font-size: 13px;
}
.topbar .actions {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
}
.project-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
.project-bar select,
.project-bar input[type=text] {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font: inherit;
font-size: 13px;
width: auto;
min-width: 120px;
}
.project-bar select { min-width: 140px; }
.save-status {
color: var(--muted);
font-size: 11px;
font-family: "SF Mono", Consolas, monospace;
margin-left: 4px;
min-width: 50px;
}
.topbar-status {
color: var(--muted);
font-size: 12px;
padding-left: 8px;
margin-left: 4px;
border-left: 1px solid var(--border);
white-space: nowrap;
}
.save-status.dirty { color: var(--accent); }
.save-status.saving { color: var(--accent-2); }
.save-status.saved { color: #3ecf8e; }
.save-status.error { color: var(--danger); }
button.danger {
background: var(--panel);
color: var(--danger);
border-color: var(--danger);
}
button.danger:hover {
background: var(--danger);
color: #fff;
}
/* ---- modal ---- */
.modal {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.modal.hidden { display: none; }
.modal-content {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
max-width: 560px;
width: 90vw;
max-height: 80vh;
display: flex; flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px;
background: var(--panel-2);
border-bottom: 1px solid var(--border);
}
.modal-header h3 { margin: 0; font-size: 14px; }
.modal-close {
background: transparent; border: 0;
color: var(--muted); font-size: 20px; line-height: 1;
cursor: pointer; padding: 0 4px;
}
.modal-close:hover { color: var(--text); }
.modal-body {
padding: 12px 14px;
overflow-y: auto;
}
.history-list {
list-style: none; margin: 0; padding: 0;
}
.history-item {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 4px;
background: var(--bg);
}
.history-item .time {
font-family: "SF Mono", Consolas, monospace;
font-size: 12px;
color: var(--muted);
min-width: 140px;
}
.history-item .note { flex: 1; color: var(--text); font-size: 12px; }
.history-item button { padding: 2px 8px; font-size: 11px; }
.topbar .sep {
width: 1px;
height: 22px;
background: var(--border);
margin: 0 6px;
}
/* ---- main grid ---- */
main {
display: flex;
flex: 1;
min-height: 0;
}
.left {
width: 380px;
flex-shrink: 0;
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px;
}
.right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg);
}
.viewport-wrap {
flex: 1 1 auto;
min-height: 0;
position: relative;
overflow: hidden;
}
.step-preview-wrap {
flex: 0 0 auto;
border-top: 1px solid var(--border);
background: var(--panel);
display: flex;
flex-direction: column;
overflow: hidden;
height: 280px;
}
.step-preview-wrap.collapsed {
height: auto;
}
.step-preview-wrap.collapsed .step-preview {
display: none;
}
.step-preview-wrap.collapsed .step-preview-collapse {
transform: rotate(180deg);
}
.step-preview-collapse {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 6px;
font-size: 10px;
line-height: 1;
cursor: pointer;
margin-right: 4px;
}
.step-preview-collapse:hover { color: var(--text); }
.step-preview-header {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
background: var(--panel-2);
font-size: 12px;
}
.step-preview-header .hint { margin: 0; font-size: 11px; }
.step-preview {
flex: 1;
overflow: auto;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
}
.step-preview svg {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
.step-preview-empty,
.step-preview-err {
color: var(--muted);
font-size: 12px;
padding: 16px;
text-align: center;
}
.step-preview-err { color: var(--danger); }
.preset-row {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.preset-row select { flex: 1; min-width: 140px; }
.preset-row button { padding: 4px 8px; font-size: 11px; }
.preset-row .hint { margin: 0; font-size: 11px; flex-basis: 100%; }
#viewport {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.viewport-overlay {
position: absolute;
bottom: 8px;
left: 12px;
display: flex;
gap: 16px;
pointer-events: none;
color: var(--muted);
font-size: 12px;
font-family: "SF Mono", Consolas, monospace;
background: rgba(15, 17, 21, 0.6);
padding: 4px 8px;
border-radius: 4px;
}
/* ---- panels ---- */
.panel {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.panel h2 {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 0 0 8px;
}
.hint code {
background: rgba(255,255,255,0.05);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
/* ---- tabs ---- */
.tabs {
display: flex;
gap: 2px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.tab {
flex: 1;
background: transparent;
color: var(--muted);
border: 0;
border-bottom: 2px solid transparent;
padding: 6px 8px;
cursor: pointer;
font-size: 12px;
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.tab-body { display: block; }
.tab-body.hidden { display: none; }
/* ---- forms ---- */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
label.checkbox {
flex-direction: row;
align-items: center;
gap: 6px;
text-transform: none;
letter-spacing: 0;
font-size: 12px;
color: var(--text);
}
input[type=number], input[type=text], select, textarea {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 8px;
font: inherit;
width: 100%;
}
textarea {
resize: vertical;
font-family: "SF Mono", Consolas, monospace;
font-size: 12px;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent-2);
}
button {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font: inherit;
}
button:hover { background: var(--panel-2); }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #1a1a1a;
font-weight: 600;
}
button.primary:hover { background: #ff9933; }
.tab-body button {
margin-top: 8px;
}
/* ---- busbars ---- */
.busbar-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.sel-info {
color: var(--muted);
font-size: 12px;
margin-left: auto;
}
.busbar-list {
list-style: none;
margin: 0;
padding: 0;
}
.busbar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 6px;
}
.busbar-item.active {
border-color: var(--accent);
}
.busbar-color {
width: 16px;
height: 16px;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.2);
}
.busbar-name {
flex: 1;
background: transparent;
border: 0;
color: var(--text);
padding: 2px 4px;
font: inherit;
}
.busbar-name:focus { background: var(--panel); border-radius: 3px; }
.busbar-count {
color: var(--muted);
font-size: 11px;
font-family: "SF Mono", Consolas, monospace;
}
.busbar-shape {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 3px;
font-size: 11px;
padding: 1px 4px;
width: auto;
}
.busbar-actions { display: flex; gap: 4px; }
.busbar-actions button {
padding: 2px 6px;
font-size: 11px;
}
.busbar-actions .del { color: var(--danger); border-color: var(--danger); }
@media (max-width: 1000px) {
.left { width: 320px; }
}