Sidebar tabs UI, S×P pack sizing, per-busbar STEP or DXF export

Replace the single stacked config pane with a top tab strip (Pack, Cells, BMS,
Busbars) so only one group is visible at a time. Pack sizing defaults to
Series × Parallel with a summary chip that shows count and footprint, with a
mm-size mode for users who prefer direct dimensions. Cells and Housing are
merged under one tab separated by a divider.

Exports are now per component: the cellholder is a STEP solid, and each busbar
downloads as either STEP (3D stencil) or DXF (flat laser cut pattern) chosen by
a dropdown. Mirrored and rotated duplicates are deduplicated by a
pairwise-distance signature so only one copy per shape class is emitted.

The DXF writer produces just the union outline: pads as CIRCLE or ARC entities
restricted to uncovered angular ranges, capsule sides as LINE entities clipped
(Liang-Barsky + disc intersection) against every other primitive so no lines
remain inside the busbar body.

README documents setup, features, and project layout.
This commit is contained in:
Maxim
2026-04-22 23:35:12 +02:00
parent 5e010bd2b6
commit 6996efcf23
7 changed files with 994 additions and 183 deletions

View File

@@ -10,6 +10,7 @@ import {
import { drawPreview, clearCanvas } from './preview.js';
import { create3DModel } from './model.js';
import { downloadSTEP } from './step-export.js';
import { buildBusbarDXF, downloadDXF } from './dxf-export.js';
import { busbarStore } from './busbars.js';
import { computeBusbarGeometry } from './busbar-geometry.js';
import { drawBusbarsOverlay } from './busbar-preview.js';
@@ -453,7 +454,7 @@ export async function generateLayout() {
const bb = busbarStore.list[i];
const geom = busbarGeometries[i];
if (geom.blocked) {
showStatus(`${bb.name}: ${geom.blocked.reason} — cannot export`, 'error');
showStatus(`${bb.name}: ${geom.blocked.reason}. Cannot export.`, 'error');
showLoading(false);
return;
}
@@ -463,35 +464,79 @@ export async function generateLayout() {
const holderCenterY = (Math.min(...positions.map(p => p[1])) + Math.max(...positions.map(p => p[1]))) / 2;
const centeredPositions = positions.map(([x, y]) => [x - holderCenterX, y - holderCenterY]);
const busbarShapes = [];
// Sanitize a busbar name for use in a filename (ASCII letters/digits/underscores only).
const safeName = (name) => (name || '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'busbar';
// Signature invariant under rigid motion AND reflection. Two busbars with equal
// signatures are congruent, so we only need to print one copy. Uses sorted
// pairwise cell distances; pairwise distance sets are the same under
// translation, rotation, and mirror.
const busbarSignature = (bb) => {
const idxs = bb.cellIndices;
if (idxs.length === 0) return null;
if (idxs.length === 1) return `single|${bb.thickness.toFixed(2)}`;
const pts = idxs.map(i => centeredPositions[i]).filter(Boolean);
const dists = [];
for (let a = 0; a < pts.length; a++) {
for (let b = a + 1; b < pts.length; b++) {
dists.push(Math.hypot(pts[a][0] - pts[b][0], pts[a][1] - pts[b][1]));
}
}
dists.sort((x, y) => x - y);
return `${pts.length}|${bb.thickness.toFixed(2)}|${dists.map(d => d.toFixed(3)).join(',')}`;
};
// Export format for busbars: STEP solid or DXF flat pattern. The cellholder
// is always exported as STEP.
const busbarFormat = (document.getElementById('busbarFormat')?.value) || 'step';
// Deduplicate busbars by signature. For STEP we also need to build the 3D
// shape; for DXF we only need the geometry so we can skip the expensive build.
const uniqueBusbars = [];
const sigSeen = new Map();
for (let i = 0; i < busbarStore.list.length; i++) {
const bb = busbarStore.list[i];
if (bb.cellIndices.length === 0) continue;
const shape = build3DBusbar(busbarGeometries[i], centeredPositions, busbarPadRadius, height, bb.thickness);
if (shape) busbarShapes.push(shape);
const sig = busbarSignature(bb);
if (sig && sigSeen.has(sig)) {
sigSeen.get(sig).copies.push(bb.name);
continue;
}
const entry = { bb, geom: busbarGeometries[i], copies: [bb.name], shape: null };
if (busbarFormat === 'step') {
entry.shape = build3DBusbar(busbarGeometries[i], centeredPositions, busbarPadRadius, height, bb.thickness);
if (!entry.shape) continue;
}
uniqueBusbars.push(entry);
if (sig) sigSeen.set(sig, entry);
}
const oc = ocRef.instance;
let exportShape = holderShape;
if (busbarShapes.length > 0) {
const compound = new oc.TopoDS_Compound();
const builder = new oc.BRep_Builder();
builder.MakeCompound(compound);
builder.Add(compound, holderShape);
for (const s of busbarShapes) builder.Add(compound, s);
exportShape = compound;
// Trigger downloads sequentially with small delays so browsers allow them.
const wait = (ms) => new Promise(r => setTimeout(r, ms));
downloadSTEP(holderShape, `cellholder_${layoutType}.step`);
for (let i = 0; i < uniqueBusbars.length; i++) {
await wait(250);
const { bb, geom, shape } = uniqueBusbars[i];
const base = `busbar_${safeName(bb.name)}`;
if (busbarFormat === 'dxf') {
const content = buildBusbarDXF(geom, centeredPositions, busbarPadRadius);
downloadDXF(content, `${base}.dxf`);
} else {
downloadSTEP(shape, `${base}.step`);
}
}
const filename = `${layoutType}_layout.step`;
downloadSTEP(exportShape, filename);
const totalBusbars = busbarStore.list.filter(b => b.cellIndices.length > 0).length;
const skipped = totalBusbars - uniqueBusbars.length;
const busbarMsg = uniqueBusbars.length > 0
? `. ${uniqueBusbars.length} unique ${busbarFormat.toUpperCase()} busbar file${uniqueBusbars.length === 1 ? '' : 's'}${skipped > 0 ? ` (${skipped} mirrored duplicate${skipped === 1 ? '' : 's'} skipped)` : ''}`
: '';
const holeType = useTabs ? 'edge tabs' :
(circleHoleOffset ? 'circle offset' : 'semicircle offset');
const filletMsg = (filletBms && !useTabs) ? ' with filleted holes' : '';
const busbarMsg = busbarShapes.length > 0 ? ` + ${busbarShapes.length} busbar${busbarShapes.length === 1 ? '' : 's'}` : '';
showStatus(
`${layoutName} generated with ${positions.length} cells (${holeType}${filletMsg})${busbarMsg}`,
`${layoutName} generated. ${positions.length} cells (${holeType}${filletMsg})${busbarMsg}.`,
'success'
);
} catch (error) {