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:
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Battery Builder
|
||||
|
||||
A browser tool for designing 3D printable cell holders and laser or plasma cut busbars for custom battery packs. Everything runs client side in the browser, no server, no install.
|
||||
|
||||
## What you get
|
||||
|
||||
- A parametric cell holder generated as a STEP solid, ready for CAD or 3D printing.
|
||||
- One STEP or DXF file per busbar, with mirrored duplicates deduplicated automatically.
|
||||
- Live 2D preview with pan, zoom and click to assign cells to busbars.
|
||||
|
||||
## Features
|
||||
|
||||
### Pack configuration
|
||||
- **Series and Parallel** inputs or a direct **Size in mm** mode. S and P feed a hidden pack footprint so the existing layout generators stay unchanged.
|
||||
- **Cell diameter, spacing, layout type** (grid, horizontal honeycomb, vertical honeycomb).
|
||||
- **Housing options**: ledge thickness and width, optional rounded corners.
|
||||
- **BMS access**: full circles, half circles, edge tabs, or none. Hole diameter and tab dimensions are configurable.
|
||||
|
||||
### Busbars
|
||||
- Click on a cell in the preview to add it to the active busbar.
|
||||
- Per busbar color, name, and thickness.
|
||||
- Automatic routing with bend waypoints when a straight capsule would collide with a neighbour cell (keepout uses the 4 mm terminal radius).
|
||||
- Live 2D preview of each busbar with pad circles and capsule bodies.
|
||||
|
||||
### Export
|
||||
- **Cellholder**: STEP solid (`cellholder_<layout>.step`).
|
||||
- **Busbars**: choose STEP (3D stencil for CAD or 3D printing) or DXF (flat outline for laser or plasma cutters). The DXF contains only the union outline, pads emitted as `ARC` plus `CIRCLE` and capsule sides as clipped `LINE` entities with interior lines removed.
|
||||
- **Mirrored dedup**: a signature based on sorted pairwise cell distances is invariant under rotation and reflection, so two mirrored or rotated busbars produce one file. Flip the printed or cut piece at install time.
|
||||
- Files download sequentially with a short delay so browsers don't block them.
|
||||
|
||||
## Running it
|
||||
|
||||
Any static file server will work. The project ships an OpenCascade WASM build under `vendor/`.
|
||||
|
||||
```bash
|
||||
python -m http.server 8000
|
||||
# or
|
||||
npx serve .
|
||||
```
|
||||
|
||||
Then open `http://localhost:8000`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
index.html sidebar tabs + canvas
|
||||
styles/main.css dark theme, tab strip, segmented toggle, custom selects
|
||||
src/
|
||||
main.js input wiring, tab switching, S/P sync, canvas interaction
|
||||
app.js updatePreview, generateLayout, export orchestration
|
||||
state.js canvasState + positionCache
|
||||
layouts.js grid and honeycomb cell layout generators
|
||||
model.js 3D cellholder builder (OpenCascade)
|
||||
step-export.js STEP writer
|
||||
dxf-export.js DXF writer with union-outline clipping
|
||||
busbars.js busbar store (list, active id, subscribe)
|
||||
busbar-geometry.js adjacency graph, capsule routing, bend waypoints
|
||||
busbar-preview.js 2D canvas overlay for busbars
|
||||
busbar-model.js 3D busbar builder (OpenCascade)
|
||||
busbar-ui.js busbar list sidebar
|
||||
ui.js custom select, loading overlay, status toast
|
||||
preview.js 2D canvas cell holder renderer
|
||||
oc.js OpenCascade WASM bootstrap
|
||||
vendor/
|
||||
opencascade.wasm.js OpenCascade JS binding
|
||||
```
|
||||
|
||||
## Tech
|
||||
|
||||
- Vanilla ES modules, no build step.
|
||||
- OpenCascade WASM for CAD operations and STEP export.
|
||||
- HTML5 Canvas 2D for the preview with DPR scaling and pan or pinch zoom.
|
||||
- DXF is hand rolled AutoCAD R12 so it opens cleanly in LightBurn, Fusion, and the usual suspects.
|
||||
102
index.html
102
index.html
@@ -22,32 +22,90 @@
|
||||
|
||||
<div class="main-layout">
|
||||
<div class="config-sidebar">
|
||||
<div class="section">
|
||||
<h2>Configuration</h2>
|
||||
<nav class="sidebar-tabs" role="tablist" data-tabs>
|
||||
<button type="button" role="tab" class="tab active" data-panel="pack" aria-selected="true">
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="6.5" width="15" height="11" rx="1.5"/>
|
||||
<line x1="18" y1="10" x2="21" y2="10"/>
|
||||
<line x1="18" y1="14" x2="21" y2="14"/>
|
||||
</svg>
|
||||
<span class="tab-label">Pack</span>
|
||||
</button>
|
||||
<button type="button" role="tab" class="tab" data-panel="cells" aria-selected="false">
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="7.5" cy="12" r="3.2"/>
|
||||
<circle cx="15.5" cy="8" r="3.2"/>
|
||||
<circle cx="15.5" cy="16" r="3.2"/>
|
||||
</svg>
|
||||
<span class="tab-label">Cells</span>
|
||||
</button>
|
||||
<button type="button" role="tab" class="tab" data-panel="bms" aria-selected="false">
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M13 3l-8 11h6l-1 7 8-11h-6l1-7z"/>
|
||||
</svg>
|
||||
<span class="tab-label">BMS</span>
|
||||
</button>
|
||||
<button type="button" role="tab" class="tab" data-panel="busbars" aria-selected="false">
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="6" cy="12" r="2"/>
|
||||
<circle cx="18" cy="12" r="2"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
<line x1="6" y1="5" x2="18" y2="5"/>
|
||||
<line x1="6" y1="19" x2="18" y2="19"/>
|
||||
</svg>
|
||||
<span class="tab-label">Busbars</span>
|
||||
</button>
|
||||
<span class="tab-indicator" aria-hidden="true"></span>
|
||||
</nav>
|
||||
|
||||
<h3>Pack Dimensions</h3>
|
||||
<div class="tab-panels">
|
||||
<section class="tab-panel active" role="tabpanel" data-panel="pack">
|
||||
<div class="seg-toggle" data-pack-mode data-mode="sp">
|
||||
<button type="button" class="seg active" data-mode="sp">Series × Parallel</button>
|
||||
<button type="button" class="seg" data-mode="mm">Size (mm)</button>
|
||||
<span class="seg-indicator" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<div class="pack-fields pack-sp-fields">
|
||||
<div class="form-group">
|
||||
<label>Series (S)</label>
|
||||
<input type="number" id="series" value="7" min="1" step="1">
|
||||
<span class="field-hint">Cells stacked in series. Sets voltage.</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Parallel (P)</label>
|
||||
<input type="number" id="parallel" value="5" min="1" step="1">
|
||||
<span class="field-hint">Cells bundled in parallel. Sets capacity.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pack-fields pack-mm-fields" hidden>
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label>Width (mm)</label>
|
||||
<input type="number" id="xDim" value="150">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Length (mm)</label>
|
||||
<label>Depth (mm)</label>
|
||||
<input type="number" id="yDim" value="100">
|
||||
</div>
|
||||
</div>
|
||||
<span class="field-hint">Cells are fit automatically inside the footprint.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Height (mm)</label>
|
||||
<label>Holder Thickness (mm)</label>
|
||||
<input type="number" id="height" value="10">
|
||||
</div>
|
||||
|
||||
<h3>Cell Configuration</h3>
|
||||
<div class="pack-summary" id="packSummary" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" role="tabpanel" data-panel="cells">
|
||||
<div class="form-group">
|
||||
<label>Cell Diameter (mm)</label>
|
||||
<input type="number" id="cellSize" value="21.35">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Layout Type</label>
|
||||
<select id="layoutType">
|
||||
@@ -56,30 +114,30 @@
|
||||
<option value="vertical">Vertical Honeycomb</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Cell Spacing (mm)</label>
|
||||
<input type="number" id="spacing" value="0.6">
|
||||
</div>
|
||||
|
||||
<div class="panel-divider" aria-hidden="true"></div>
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label>Ledge Thickness (mm)</label>
|
||||
<input type="number" id="coverThickness" value="0.4">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Ledge Width (mm)</label>
|
||||
<input type="number" id="ledgeWidth" value="2.75">
|
||||
</div>
|
||||
|
||||
<h3>Features</h3>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="roundedCorners" checked>
|
||||
<label for="roundedCorners">Rounded Corners</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" role="tabpanel" data-panel="bms">
|
||||
<div class="form-group">
|
||||
<label>BMS Holes</label>
|
||||
<label>Opening Type</label>
|
||||
<select id="bmsHolesType">
|
||||
<option value="off">Off</option>
|
||||
<option value="halfcircles">Half Circles</option>
|
||||
@@ -87,12 +145,10 @@
|
||||
<option value="tabs">Edge Tabs</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="bmsHoleDiameterGroup">
|
||||
<label>BMS Hole Diameter (mm)</label>
|
||||
<label>Hole Diameter (mm)</label>
|
||||
<input type="number" id="bmsHoleDiameter" value="4.0" min="1" max="10">
|
||||
</div>
|
||||
|
||||
<div class="row" id="tabDimensionsGroup" style="display:none;">
|
||||
<div class="form-group">
|
||||
<label>Tab Width (mm)</label>
|
||||
@@ -103,14 +159,24 @@
|
||||
<input type="number" id="tabDepth" value="1.0" min="0.1" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h3>Busbars</h3>
|
||||
<section class="tab-panel" role="tabpanel" data-panel="busbars">
|
||||
<div class="form-group">
|
||||
<label>Export Format</label>
|
||||
<select id="busbarFormat">
|
||||
<option value="step" selected>STEP (3D stencil)</option>
|
||||
<option value="dxf">DXF (laser cutting)</option>
|
||||
</select>
|
||||
<span class="field-hint">STEP exports a 3D solid for CAD or 3D printing. DXF exports a flat 2D outline for laser or plasma cutters.</span>
|
||||
</div>
|
||||
<div id="busbarList"></div>
|
||||
<button class="btn-secondary" id="addBusbarBtn">+ Add Busbar</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="generateBtn">Generate 3D Model</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-container">
|
||||
<h2>Preview</h2>
|
||||
|
||||
81
src/app.js
81
src/app.js
@@ -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) {
|
||||
|
||||
@@ -19,42 +19,6 @@ function addCapsuleSubpath(ctx, x1, y1, x2, y2, r) {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Tangent-arc fillet on the concave side of the CCW sector between u1 and u2.
|
||||
function addConcaveFillet(ctx, vx, vy, u1, u2, padR) {
|
||||
const p1x = -u1[1], p1y = u1[0];
|
||||
const p2x = u2[1], p2y = -u2[0];
|
||||
|
||||
const A1x = vx + padR * p1x, A1y = vy + padR * p1y;
|
||||
const A2x = vx + padR * p2x, A2y = vy + padR * p2y;
|
||||
const det = u1[0] * (-u2[1]) - (-u2[0]) * u1[1];
|
||||
if (Math.abs(det) < 1e-9) return;
|
||||
const dx = A2x - A1x, dy = A2y - A1y;
|
||||
const t = ((-u2[1]) * dx - (-u2[0]) * dy) / det;
|
||||
const Vnx = A1x + t * u1[0];
|
||||
const Vny = A1y + t * u1[1];
|
||||
|
||||
const bsx = p1x + p2x, bsy = p1y + p2y;
|
||||
const blen = Math.hypot(bsx, bsy);
|
||||
if (blen < 1e-6) return;
|
||||
const bx = bsx / blen, by = bsy / blen;
|
||||
const sinHalf = blen / 2;
|
||||
|
||||
const r = padR;
|
||||
const dist = r / sinHalf;
|
||||
const cx = Vnx + dist * bx;
|
||||
const cy = Vny + dist * by;
|
||||
|
||||
const T1x = cx - r * p1x, T1y = cy - r * p1y;
|
||||
const T2x = cx - r * p2x, T2y = cy - r * p2y;
|
||||
|
||||
const angleT1 = Math.atan2(T1y - cy, T1x - cx);
|
||||
const angleT2 = Math.atan2(T2y - cy, T2x - cx);
|
||||
ctx.moveTo(Vnx, Vny);
|
||||
ctx.lineTo(T1x, T1y);
|
||||
ctx.arc(cx, cy, r, angleT1, angleT2, true);
|
||||
ctx.lineTo(Vnx, Vny);
|
||||
}
|
||||
|
||||
export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, padRadius, spacing, activeId) {
|
||||
const canvas = document.getElementById('preview');
|
||||
if (!canvas) return;
|
||||
@@ -109,42 +73,6 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
|
||||
}
|
||||
}
|
||||
|
||||
const vertMap = new Map();
|
||||
const vkey = (sx, sy) => `${Math.round(sx * 100)},${Math.round(sy * 100)}`;
|
||||
const addDir = (sx, sy, dx, dy) => {
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-6) return;
|
||||
const k = vkey(sx, sy);
|
||||
if (!vertMap.has(k)) vertMap.set(k, { x: sx, y: sy, dirs: [] });
|
||||
vertMap.get(k).dirs.push([dx / len, dy / len]);
|
||||
};
|
||||
|
||||
for (const edge of geom.edges) {
|
||||
const pts = [positions[edge.from], ...edge.waypoints, positions[edge.to]];
|
||||
for (let k = 0; k < pts.length - 1; k++) {
|
||||
const ax = toScreenX(pts[k][0]), ay = toScreenY(pts[k][1]);
|
||||
const bx = toScreenX(pts[k + 1][0]), by = toScreenY(pts[k + 1][1]);
|
||||
addDir(ax, ay, bx - ax, by - ay);
|
||||
addDir(bx, by, ax - bx, ay - by);
|
||||
}
|
||||
}
|
||||
|
||||
for (const v of vertMap.values()) {
|
||||
if (v.dirs.length < 2) continue;
|
||||
const sorted = v.dirs.slice().sort((a, b) => Math.atan2(a[1], a[0]) - Math.atan2(b[1], b[0]));
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const u1 = sorted[i];
|
||||
const u2 = sorted[(i + 1) % sorted.length];
|
||||
const a1 = Math.atan2(u1[1], u1[0]);
|
||||
const a2 = Math.atan2(u2[1], u2[0]);
|
||||
let gap = a2 - a1;
|
||||
if (gap <= 0) gap += TWO_PI;
|
||||
if (gap > 0 && gap < Math.PI - 1e-3) {
|
||||
addConcaveFillet(ctx, v.x, v.y, u1, u2, padRadiusScreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = hexToRgba(busbar.color, isActive ? 1.0 : 0.85);
|
||||
|
||||
289
src/dxf-export.js
Normal file
289
src/dxf-export.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// DXF (AutoCAD R12) writer that emits only the union outline of the busbar body
|
||||
// (pads + capsule rectangles). Each pad contributes the arcs of its circle that
|
||||
// aren't covered by an attached capsule's half-disc; each capsule contributes the
|
||||
// portions of its two side lines that lie outside every other primitive. CAM
|
||||
// software stitches the resulting ARC + LINE entities into closed cut paths.
|
||||
|
||||
function dxfHeader() {
|
||||
return [
|
||||
'0', 'SECTION',
|
||||
'2', 'HEADER',
|
||||
'9', '$ACADVER', '1', 'AC1009',
|
||||
'9', '$INSUNITS', '70', '4',
|
||||
'0', 'ENDSEC',
|
||||
];
|
||||
}
|
||||
|
||||
function dxfTables() {
|
||||
return [
|
||||
'0', 'SECTION',
|
||||
'2', 'TABLES',
|
||||
'0', 'TABLE', '2', 'LAYER', '70', '1',
|
||||
'0', 'LAYER', '2', 'busbar', '70', '0', '62', '7', '6', 'CONTINUOUS',
|
||||
'0', 'ENDTAB',
|
||||
'0', 'ENDSEC',
|
||||
];
|
||||
}
|
||||
|
||||
function circleEntity(cx, cy, r, layer) {
|
||||
return [
|
||||
'0', 'CIRCLE', '8', layer,
|
||||
'10', cx.toFixed(4), '20', cy.toFixed(4), '30', '0.0',
|
||||
'40', r.toFixed(4),
|
||||
];
|
||||
}
|
||||
|
||||
function arcEntity(cx, cy, r, startRad, endRad, layer) {
|
||||
const toDeg = (a) => (a * 180 / Math.PI);
|
||||
return [
|
||||
'0', 'ARC', '8', layer,
|
||||
'10', cx.toFixed(4), '20', cy.toFixed(4), '30', '0.0',
|
||||
'40', r.toFixed(4),
|
||||
'50', toDeg(startRad).toFixed(4),
|
||||
'51', toDeg(endRad).toFixed(4),
|
||||
];
|
||||
}
|
||||
|
||||
function lineEntity(x1, y1, x2, y2, layer) {
|
||||
return [
|
||||
'0', 'LINE', '8', layer,
|
||||
'10', x1.toFixed(4), '20', y1.toFixed(4), '30', '0.0',
|
||||
'11', x2.toFixed(4), '21', y2.toFixed(4), '31', '0.0',
|
||||
];
|
||||
}
|
||||
|
||||
const TWO_PI = 2 * Math.PI;
|
||||
const EPS = 1e-5;
|
||||
const normAngle = (a) => { const m = a % TWO_PI; return m < 0 ? m + TWO_PI : m; };
|
||||
const angularDist = (a, b) => {
|
||||
const d = Math.abs(normAngle(a) - normAngle(b));
|
||||
return d > Math.PI ? TWO_PI - d : d;
|
||||
};
|
||||
|
||||
// Clip a segment against a circle disc. Returns [[t0, t1], ...] intervals in [0, 1]
|
||||
// where the segment lies strictly inside the disc (radius r at center c).
|
||||
function segInsideCircle(p, q, c, r) {
|
||||
const dx = q[0] - p[0], dy = q[1] - p[1];
|
||||
const fx = p[0] - c[0], fy = p[1] - c[1];
|
||||
const A = dx * dx + dy * dy;
|
||||
if (A < EPS) return [];
|
||||
const B = 2 * (fx * dx + fy * dy);
|
||||
const C = fx * fx + fy * fy - r * r;
|
||||
const disc = B * B - 4 * A * C;
|
||||
if (disc <= 0) return [];
|
||||
const sq = Math.sqrt(disc);
|
||||
const t1 = (-B - sq) / (2 * A);
|
||||
const t2 = (-B + sq) / (2 * A);
|
||||
const lo = Math.max(0, t1);
|
||||
const hi = Math.min(1, t2);
|
||||
if (hi - lo < EPS) return [];
|
||||
return [[lo, hi]];
|
||||
}
|
||||
|
||||
// Clip a segment against a rotated axis-aligned capsule rectangle (Minkowski body
|
||||
// without the end caps). Returns intervals in [0, 1] strictly inside the rectangle.
|
||||
function segInsideCapsule(p, q, aStart, aEnd, halfWidth) {
|
||||
const ax = aStart[0], ay = aStart[1];
|
||||
const bx = aEnd[0], by = aEnd[1];
|
||||
const ux = bx - ax, uy = by - ay;
|
||||
const len = Math.hypot(ux, uy);
|
||||
if (len < EPS) return [];
|
||||
const cos = ux / len, sin = uy / len;
|
||||
const toLocal = (pt) => [
|
||||
(pt[0] - ax) * cos + (pt[1] - ay) * sin,
|
||||
-(pt[0] - ax) * sin + (pt[1] - ay) * cos,
|
||||
];
|
||||
const lp = toLocal(p);
|
||||
const lq = toLocal(q);
|
||||
// Liang–Barsky against [0, len] × [-halfWidth, halfWidth].
|
||||
let t0 = 0, t1 = 1;
|
||||
const dx = lq[0] - lp[0], dy = lq[1] - lp[1];
|
||||
const tests = [
|
||||
[-dx, lp[0] - 0],
|
||||
[ dx, len - lp[0]],
|
||||
[-dy, lp[1] - (-halfWidth)],
|
||||
[ dy, halfWidth - lp[1]],
|
||||
];
|
||||
for (const [pp, qq] of tests) {
|
||||
if (Math.abs(pp) < EPS) {
|
||||
if (qq < -EPS) return [];
|
||||
} else {
|
||||
const r = qq / pp;
|
||||
if (pp < 0) { if (r > t1 + EPS) return []; if (r > t0) t0 = r; }
|
||||
else { if (r < t0 - EPS) return []; if (r < t1) t1 = r; }
|
||||
}
|
||||
}
|
||||
if (t1 - t0 < EPS) return [];
|
||||
return [[t0, t1]];
|
||||
}
|
||||
|
||||
function subtractIntervals(intervals) {
|
||||
const sorted = intervals.slice().sort((a, b) => a[0] - b[0]);
|
||||
const merged = [];
|
||||
for (const iv of sorted) {
|
||||
if (merged.length && iv[0] <= merged[merged.length - 1][1] + EPS) {
|
||||
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], iv[1]);
|
||||
} else {
|
||||
merged.push([iv[0], iv[1]]);
|
||||
}
|
||||
}
|
||||
const outside = [];
|
||||
let last = 0;
|
||||
for (const [a, b] of merged) {
|
||||
if (a > last + EPS) outside.push([last, Math.min(a, 1)]);
|
||||
last = Math.max(last, b);
|
||||
}
|
||||
if (last < 1 - EPS) outside.push([last, 1]);
|
||||
return outside;
|
||||
}
|
||||
|
||||
// Is an angle inside any covered half-disc at this pad?
|
||||
function angleCovered(theta, dirs) {
|
||||
for (const d of dirs) {
|
||||
if (angularDist(theta, d) < Math.PI / 2 - EPS) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildBusbarDXF(geometry, positions, padRadius) {
|
||||
const layer = 'busbar';
|
||||
const tokens = [...dxfHeader(), ...dxfTables(), '0', 'SECTION', '2', 'ENTITIES'];
|
||||
|
||||
// Enumerate pads (cells + waypoints) and capsule segments with shared keys so we
|
||||
// can tell which shapes are "self" vs "other" when clipping.
|
||||
const pads = new Map();
|
||||
const caps = []; // { a, b, padKeyA, padKeyB }
|
||||
|
||||
const ensurePad = (key, pos) => {
|
||||
if (!pads.has(key)) pads.set(key, { pos, dirs: [] });
|
||||
return pads.get(key);
|
||||
};
|
||||
|
||||
for (const idx of geometry.padIndices) {
|
||||
const p = positions[idx];
|
||||
if (!p) continue;
|
||||
ensurePad(`c${idx}`, p);
|
||||
}
|
||||
|
||||
geometry.edges.forEach((edge, ei) => {
|
||||
const from = positions[edge.from];
|
||||
const to = positions[edge.to];
|
||||
if (!from || !to) return;
|
||||
const stops = [
|
||||
{ key: `c${edge.from}`, pos: from },
|
||||
...edge.waypoints.map((wp, wi) => ({ key: `w${ei}_${wi}`, pos: wp })),
|
||||
{ key: `c${edge.to}`, pos: to },
|
||||
];
|
||||
for (let i = 1; i < stops.length - 1; i++) ensurePad(stops[i].key, stops[i].pos);
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const a = stops[i], b = stops[i + 1];
|
||||
const dx = b.pos[0] - a.pos[0];
|
||||
const dy = b.pos[1] - a.pos[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < EPS) continue;
|
||||
const ang = Math.atan2(dy, dx);
|
||||
ensurePad(a.key, a.pos).dirs.push(ang);
|
||||
ensurePad(b.key, b.pos).dirs.push(ang + Math.PI);
|
||||
caps.push({ a: a.pos.slice(), b: b.pos.slice(), padKeyA: a.key, padKeyB: b.key });
|
||||
}
|
||||
});
|
||||
|
||||
// Pad arcs: parts of each circle not covered by any connected capsule half-disc
|
||||
// AND not interior to any other capsule rectangle or pad circle.
|
||||
const padList = Array.from(pads.entries()).map(([key, data]) => ({ key, ...data }));
|
||||
for (const pad of padList) {
|
||||
const [cx, cy] = pad.pos;
|
||||
if (pad.dirs.length === 0) {
|
||||
tokens.push(...circleEntity(cx, cy, padRadius, layer));
|
||||
continue;
|
||||
}
|
||||
const tangents = [];
|
||||
for (const d of pad.dirs) {
|
||||
tangents.push(normAngle(d - Math.PI / 2));
|
||||
tangents.push(normAngle(d + Math.PI / 2));
|
||||
}
|
||||
tangents.sort((a, b) => a - b);
|
||||
const uniq = [];
|
||||
for (const t of tangents) {
|
||||
if (uniq.length === 0 || Math.abs(t - uniq[uniq.length - 1]) > EPS) uniq.push(t);
|
||||
}
|
||||
for (let i = 0; i < uniq.length; i++) {
|
||||
const t1 = uniq[i];
|
||||
const t2 = uniq[(i + 1) % uniq.length];
|
||||
const span = (i + 1 < uniq.length) ? (t2 - t1) : (TWO_PI - t1 + t2);
|
||||
if (span < EPS) continue;
|
||||
const mid = normAngle(t1 + span / 2);
|
||||
if (angleCovered(mid, pad.dirs)) continue;
|
||||
// Also require the arc's midpoint to be outside all other shapes so it's
|
||||
// genuinely on the outline — an arc that enters a neighbour's capsule is
|
||||
// interior and must be dropped.
|
||||
const mx = cx + padRadius * Math.cos(mid);
|
||||
const my = cy + padRadius * Math.sin(mid);
|
||||
let buried = false;
|
||||
for (const cap of caps) {
|
||||
if (cap.padKeyA === pad.key || cap.padKeyB === pad.key) continue;
|
||||
if (segInsideCapsule([mx, my], [mx, my], cap.a, cap.b, padRadius).length) { buried = true; break; }
|
||||
}
|
||||
if (!buried) {
|
||||
for (const other of padList) {
|
||||
if (other.key === pad.key) continue;
|
||||
const dx = mx - other.pos[0], dy = my - other.pos[1];
|
||||
if (dx * dx + dy * dy < padRadius * padRadius - EPS) { buried = true; break; }
|
||||
}
|
||||
}
|
||||
if (!buried) tokens.push(...arcEntity(cx, cy, padRadius, t1, t2, layer));
|
||||
}
|
||||
}
|
||||
|
||||
// Capsule side lines, clipped against every other pad circle and every other
|
||||
// capsule rectangle. Only the portions that remain outside everything else
|
||||
// contribute to the cut path.
|
||||
for (let ci = 0; ci < caps.length; ci++) {
|
||||
const cap = caps[ci];
|
||||
const dx = cap.b[0] - cap.a[0];
|
||||
const dy = cap.b[1] - cap.a[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < EPS) continue;
|
||||
const nx = -dy / len * padRadius;
|
||||
const ny = dx / len * padRadius;
|
||||
const sides = [
|
||||
{ p: [cap.a[0] + nx, cap.a[1] + ny], q: [cap.b[0] + nx, cap.b[1] + ny] },
|
||||
{ p: [cap.a[0] - nx, cap.a[1] - ny], q: [cap.b[0] - nx, cap.b[1] - ny] },
|
||||
];
|
||||
for (const side of sides) {
|
||||
const insides = [];
|
||||
for (let j = 0; j < caps.length; j++) {
|
||||
if (j === ci) continue;
|
||||
insides.push(...segInsideCapsule(side.p, side.q, caps[j].a, caps[j].b, padRadius));
|
||||
}
|
||||
for (const pad of padList) {
|
||||
if (pad.key === cap.padKeyA || pad.key === cap.padKeyB) continue;
|
||||
insides.push(...segInsideCircle(side.p, side.q, pad.pos, padRadius));
|
||||
}
|
||||
const outside = subtractIntervals(insides);
|
||||
for (const [t0, t1] of outside) {
|
||||
if (t1 - t0 < 1e-3) continue; // drop microscopic fragments
|
||||
const sx = side.p[0] + t0 * (side.q[0] - side.p[0]);
|
||||
const sy = side.p[1] + t0 * (side.q[1] - side.p[1]);
|
||||
const ex = side.p[0] + t1 * (side.q[0] - side.p[0]);
|
||||
const ey = side.p[1] + t1 * (side.q[1] - side.p[1]);
|
||||
tokens.push(...lineEntity(sx, sy, ex, ey, layer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push('0', 'ENDSEC', '0', 'EOF');
|
||||
return tokens.join('\n') + '\n';
|
||||
}
|
||||
|
||||
export function downloadDXF(content, filename) {
|
||||
const blob = new Blob([content], { type: 'application/dxf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
177
src/main.js
177
src/main.js
@@ -21,20 +21,111 @@ function scaleCanvasForDPI() {
|
||||
canvas.style.height = rect.height + 'px';
|
||||
}
|
||||
|
||||
function getPackMode() {
|
||||
const el = document.querySelector('[data-pack-mode]');
|
||||
return (el && el.dataset.mode) || 'sp';
|
||||
}
|
||||
|
||||
// Compute the pack footprint (world mm) from Series and Parallel counts and write
|
||||
// it to the xDim / yDim inputs so the existing layout generators pick it up. When
|
||||
// the user switches to "Size (mm)" mode, xDim / yDim are entered directly and this
|
||||
// routine just refreshes the summary.
|
||||
function syncPackDimsFromSP() {
|
||||
const mode = getPackMode();
|
||||
const xEl = document.getElementById('xDim');
|
||||
const yEl = document.getElementById('yDim');
|
||||
const summary = document.getElementById('packSummary');
|
||||
|
||||
if (mode === 'mm') {
|
||||
const xDim = parseFloat(xEl.value) || 0;
|
||||
const yDim = parseFloat(yEl.value) || 0;
|
||||
if (summary) {
|
||||
summary.innerHTML =
|
||||
`<strong>${xDim.toFixed(0)} × ${yDim.toFixed(0)} mm</strong> ` +
|
||||
`<span class="muted">footprint. Cells fit automatically.</span>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const s = Math.max(1, Math.round(parseFloat(document.getElementById('series').value) || 1));
|
||||
const p = Math.max(1, Math.round(parseFloat(document.getElementById('parallel').value) || 1));
|
||||
const cellSize = parseFloat(document.getElementById('cellSize').value) || 21.35;
|
||||
const spacing = parseFloat(document.getElementById('spacing').value) || 0.6;
|
||||
const layoutType = document.getElementById('layoutType').value;
|
||||
|
||||
const gridStride = cellSize + spacing;
|
||||
const hexStride = Math.sqrt(3) / 2 * gridStride;
|
||||
const EPS = 0.02;
|
||||
|
||||
const gridSpan = (n) => cellSize + 2 * spacing + (n - 1) * gridStride + EPS;
|
||||
const hexSpan = (n) => cellSize + 2 * spacing + (n - 1) * hexStride + EPS;
|
||||
// Offset packing: size for the shifted row so both rows fit n cells.
|
||||
const offsetSpan = (n) => cellSize + 2 * spacing + (n - 1) * gridStride + gridStride / 2 + EPS;
|
||||
|
||||
let xDim, yDim;
|
||||
if (layoutType === 'vertical') {
|
||||
xDim = hexSpan(s);
|
||||
yDim = offsetSpan(p);
|
||||
} else if (layoutType === 'honeycomb') {
|
||||
xDim = offsetSpan(s);
|
||||
yDim = hexSpan(p);
|
||||
} else {
|
||||
xDim = gridSpan(s);
|
||||
yDim = gridSpan(p);
|
||||
}
|
||||
|
||||
xEl.value = xDim.toFixed(2);
|
||||
yEl.value = yDim.toFixed(2);
|
||||
|
||||
if (summary) {
|
||||
const total = s * p;
|
||||
summary.innerHTML =
|
||||
`<strong>${s}S ${p}P</strong>. ${total} cells. ` +
|
||||
`<span class="muted">Footprint about ${xDim.toFixed(0)} × ${yDim.toFixed(0)} mm.</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function wireInputs() {
|
||||
const layoutInputs = ['xDim', 'yDim', 'spacing', 'cellSize', 'layoutType'];
|
||||
// Series/Parallel drive xDim/yDim in SP mode. When the user types into them (or
|
||||
// into spacing/cellSize/layoutType) we resync and re-render.
|
||||
const spInputs = ['series', 'parallel'];
|
||||
spInputs.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('input', () => { syncPackDimsFromSP(); updatePreview(true); });
|
||||
el.addEventListener('change', () => busbarStore.clearAll());
|
||||
});
|
||||
|
||||
// In mm mode xDim / yDim are user inputs; refresh summary and preview directly.
|
||||
const mmInputs = ['xDim', 'yDim'];
|
||||
mmInputs.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('input', () => {
|
||||
if (getPackMode() === 'mm') {
|
||||
syncPackDimsFromSP();
|
||||
updatePreview(true);
|
||||
}
|
||||
});
|
||||
el.addEventListener('change', () => {
|
||||
if (getPackMode() === 'mm') busbarStore.clearAll();
|
||||
});
|
||||
});
|
||||
|
||||
const layoutInputs = ['spacing', 'cellSize', 'layoutType'];
|
||||
layoutInputs.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('change', () => busbarStore.clearAll());
|
||||
});
|
||||
|
||||
const dimensionInputs = ['xDim', 'yDim', 'spacing', 'cellSize', 'layoutType', 'height', 'coverThickness'];
|
||||
const dimensionInputs = ['spacing', 'cellSize', 'layoutType', 'height', 'coverThickness'];
|
||||
dimensionInputs.forEach(id => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.addEventListener('input', () => updatePreview(true));
|
||||
element.addEventListener('change', () => updatePreview(true));
|
||||
const handler = () => { syncPackDimsFromSP(); updatePreview(true); };
|
||||
element.addEventListener('input', handler);
|
||||
element.addEventListener('change', handler);
|
||||
});
|
||||
|
||||
const visualInputs = ['bmsHolesType', 'roundedCorners', 'bmsHoleDiameter', 'ledgeWidth', 'tabWidth', 'tabDepth'];
|
||||
@@ -46,6 +137,41 @@ function wireInputs() {
|
||||
});
|
||||
}
|
||||
|
||||
function wirePackMode() {
|
||||
const toggle = document.querySelector('[data-pack-mode]');
|
||||
if (!toggle) return;
|
||||
const buttons = Array.from(toggle.querySelectorAll('.seg'));
|
||||
const indicator = toggle.querySelector('.seg-indicator');
|
||||
const spFields = document.querySelector('.pack-sp-fields');
|
||||
const mmFields = document.querySelector('.pack-mm-fields');
|
||||
|
||||
const moveIndicator = (btn) => {
|
||||
if (!indicator || !btn) return;
|
||||
indicator.style.left = btn.offsetLeft + 'px';
|
||||
indicator.style.width = btn.offsetWidth + 'px';
|
||||
};
|
||||
|
||||
const apply = (mode) => {
|
||||
toggle.dataset.mode = mode;
|
||||
buttons.forEach(b => {
|
||||
const on = b.dataset.mode === mode;
|
||||
b.classList.toggle('active', on);
|
||||
if (on) moveIndicator(b);
|
||||
});
|
||||
if (spFields) spFields.hidden = mode !== 'sp';
|
||||
if (mmFields) mmFields.hidden = mode !== 'mm';
|
||||
syncPackDimsFromSP();
|
||||
updatePreview(true);
|
||||
busbarStore.clearAll();
|
||||
};
|
||||
|
||||
buttons.forEach(b => b.addEventListener('click', () => apply(b.dataset.mode)));
|
||||
requestAnimationFrame(() => {
|
||||
const active = buttons.find(b => b.classList.contains('active')) || buttons[0];
|
||||
if (active) moveIndicator(active);
|
||||
});
|
||||
}
|
||||
|
||||
function redrawFromState() {
|
||||
if (canvasState.currentPositions.length > 0) {
|
||||
drawPreview(canvasState.currentPositions, canvasState.currentCellSize);
|
||||
@@ -238,9 +364,52 @@ function wireCanvasInteractions() {
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
function wireSidebarTabs() {
|
||||
const root = document.querySelector('[data-tabs]');
|
||||
if (!root) return;
|
||||
const tabs = Array.from(root.querySelectorAll('.tab'));
|
||||
const indicator = root.querySelector('.tab-indicator');
|
||||
const panels = Array.from(document.querySelectorAll('.tab-panel'));
|
||||
|
||||
const moveIndicator = (tab) => {
|
||||
if (!indicator || !tab) return;
|
||||
indicator.style.left = tab.offsetLeft + 'px';
|
||||
indicator.style.width = tab.offsetWidth + 'px';
|
||||
};
|
||||
|
||||
const activate = (key) => {
|
||||
for (const tab of tabs) {
|
||||
const on = tab.dataset.panel === key;
|
||||
tab.classList.toggle('active', on);
|
||||
tab.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
if (on) moveIndicator(tab);
|
||||
}
|
||||
for (const panel of panels) {
|
||||
panel.classList.toggle('active', panel.dataset.panel === key);
|
||||
}
|
||||
};
|
||||
|
||||
for (const tab of tabs) {
|
||||
tab.addEventListener('click', () => activate(tab.dataset.panel));
|
||||
}
|
||||
|
||||
// Set initial indicator position after layout settles.
|
||||
requestAnimationFrame(() => {
|
||||
const active = tabs.find(t => t.classList.contains('active')) || tabs[0];
|
||||
if (active) moveIndicator(active);
|
||||
});
|
||||
window.addEventListener('resize', () => {
|
||||
const active = tabs.find(t => t.classList.contains('active'));
|
||||
if (active) moveIndicator(active);
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeApp() {
|
||||
scaleCanvasForDPI();
|
||||
initCustomSelects();
|
||||
wireSidebarTabs();
|
||||
wirePackMode();
|
||||
syncPackDimsFromSP();
|
||||
|
||||
const bmsTypeSelect = document.getElementById('bmsHolesType');
|
||||
if (bmsTypeSelect) {
|
||||
|
||||
263
styles/main.css
263
styles/main.css
@@ -14,7 +14,7 @@ body {
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
transition: max-width 0.3s ease, padding 0.3s ease;
|
||||
}
|
||||
@@ -46,8 +46,8 @@ h1:hover {
|
||||
/* Main Layout */
|
||||
.main-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: 24px;
|
||||
grid-template-columns: 460px 1fr;
|
||||
gap: 28px;
|
||||
transition: grid-template-columns 0.4s cubic-bezier(0.4, 0, 0.2, 1), gap 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,229 @@ h1:hover {
|
||||
}
|
||||
|
||||
.section:nth-child(1) { animation-delay: 0.2s; }
|
||||
.section:nth-child(2) { animation-delay: 0.3s; }
|
||||
.section:nth-child(3) { animation-delay: 0.4s; }
|
||||
.section:nth-child(4) { animation-delay: 0.5s; }
|
||||
.section:nth-child(2) { animation-delay: 0.26s; }
|
||||
.section:nth-child(3) { animation-delay: 0.32s; }
|
||||
.section:nth-child(4) { animation-delay: 0.38s; }
|
||||
.section:nth-child(5) { animation-delay: 0.44s; }
|
||||
.section:nth-child(6) { animation-delay: 0.5s; }
|
||||
|
||||
/* Sidebar tab navigation — each tab shows a stacked icon + label. An animated
|
||||
gradient pill (.tab-indicator) slides to the active tab; panels cross-fade. */
|
||||
.config-sidebar {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(100, 149, 237, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 20px 20px 24px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.config-sidebar:hover {
|
||||
border-color: rgba(100, 149, 237, 0.28);
|
||||
box-shadow: 0 16px 44px rgba(100, 149, 237, 0.14);
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid rgba(100, 149, 237, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 6px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.sidebar-tabs .tab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
padding: 12px 4px 10px;
|
||||
font-size: 0.78em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.35px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: color 0.25s ease, transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sidebar-tabs .tab-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: inherit;
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.25s ease;
|
||||
}
|
||||
|
||||
.sidebar-tabs .tab-label {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-tabs .tab:hover {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.sidebar-tabs .tab:hover .tab-icon {
|
||||
transform: translateY(-1px) scale(1.05);
|
||||
}
|
||||
|
||||
.sidebar-tabs .tab:focus-visible {
|
||||
outline: 2px solid rgba(100, 149, 237, 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar-tabs .tab.active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-tabs .tab.active .tab-icon {
|
||||
color: #fff;
|
||||
transform: translateY(-1px) scale(1.08);
|
||||
}
|
||||
|
||||
.sidebar-tabs .tab-indicator {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.95), rgba(118, 75, 162, 0.9));
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 18px rgba(102, 126, 234, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transition: left 0.38s cubic-bezier(0.4, 0, 0.2, 1), width 0.38s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-panels {
|
||||
position: relative;
|
||||
padding: 4px 4px 4px;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
animation: tabFadeIn 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes tabFadeIn {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.tab-panel .form-group:last-child { margin-bottom: 0; }
|
||||
|
||||
.field-hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 0.8em;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
.pack-summary {
|
||||
margin-top: 18px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.08));
|
||||
border: 1px solid rgba(100, 149, 237, 0.25);
|
||||
border-radius: 10px;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pack-summary strong {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.05em;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.pack-summary .muted {
|
||||
color: #94a3b8;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.config-sidebar > .btn {
|
||||
margin-top: 20px;
|
||||
padding: 16px 24px;
|
||||
font-size: 1.05em;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* Segmented toggle (SP vs mm). Animated gradient pill slides to the active segment. */
|
||||
.seg-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid rgba(100, 149, 237, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
margin-bottom: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.seg-toggle .seg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1 1 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
padding: 9px 14px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.25px;
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
transition: color 0.25s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seg-toggle .seg:hover {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.seg-toggle .seg.active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.seg-toggle .seg-indicator {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.92), rgba(118, 75, 162, 0.88));
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 4px 14px rgba(102, 126, 234, 0.32);
|
||||
transition: left 0.32s cubic-bezier(0.4, 0, 0.2, 1), width 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Subtle divider between the cells group and the housing group inside the Cells tab. */
|
||||
.panel-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(100, 149, 237, 0.22), transparent);
|
||||
margin: 18px 0 20px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.section:hover {
|
||||
border-color: rgba(100, 149, 237, 0.4);
|
||||
@@ -426,9 +646,9 @@ input[type="checkbox"]:focus {
|
||||
|
||||
#preview {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
height: 720px;
|
||||
border: 1px solid rgba(100, 149, 237, 0.2);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background: #1e293b;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
@@ -558,10 +778,11 @@ input[type="checkbox"]:focus {
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1400px) {
|
||||
@media (max-width: 1500px) {
|
||||
.main-layout {
|
||||
grid-template-columns: 350px 1fr;
|
||||
grid-template-columns: 420px 1fr;
|
||||
}
|
||||
#preview { height: 640px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -578,7 +799,7 @@ input[type="checkbox"]:focus {
|
||||
}
|
||||
|
||||
#preview {
|
||||
height: 400px;
|
||||
height: 520px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,6 +959,26 @@ input[type="checkbox"]:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* Axis diagram — resolves width/depth confusion by showing which input maps to which
|
||||
on-screen dimension. Mirrors the canvas orientation: X horizontal, Y vertical, Z depth. */
|
||||
.axis-diagram {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px 12px 4px;
|
||||
margin: -4px -4px 16px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(100, 149, 237, 0.12);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.axis-diagram svg {
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Busbars */
|
||||
#busbarList {
|
||||
margin-bottom: 8px;
|
||||
|
||||
Reference in New Issue
Block a user