diff --git a/index.html b/index.html
index 351740d..d4601b7 100644
--- a/index.html
+++ b/index.html
@@ -157,6 +157,10 @@
+
+
+
+
@@ -200,6 +204,11 @@
+
+
+
+
Generate a preview first to see sheet requirements.
+
diff --git a/node_modules/.vite/deps/_metadata.json b/node_modules/.vite/deps/_metadata.json
index 4cdf495..d55fe6f 100644
--- a/node_modules/.vite/deps/_metadata.json
+++ b/node_modules/.vite/deps/_metadata.json
@@ -1,13 +1,13 @@
{
- "hash": "c96d213e",
- "configHash": "c79dcb49",
+ "hash": "8727c2cc",
+ "configHash": "cddbe005",
"lockfileHash": "6e69140d",
- "browserHash": "2de19426",
+ "browserHash": "80e0ce3b",
"optimized": {
"jszip": {
"src": "../../jszip/dist/jszip.min.js",
"file": "jszip.js",
- "fileHash": "d21eabfe",
+ "fileHash": "d6368be4",
"needsInterop": true
}
},
diff --git a/node_modules/.vite/deps_temp_6e38d81b/package.json b/node_modules/.vite/deps_temp_6e38d81b/package.json
deleted file mode 100644
index 3dbc1ca..0000000
--- a/node_modules/.vite/deps_temp_6e38d81b/package.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "type": "module"
-}
diff --git a/src/app.js b/src/app.js
index 6e0738a..02afc46 100644
--- a/src/app.js
+++ b/src/app.js
@@ -23,6 +23,35 @@ export function getLastBusbarGeometries() {
return lastComputedGeometries;
}
+let _orderUpdateCallback = null;
+export function setOrderUpdateCallback(fn) {
+ _orderUpdateCallback = fn;
+}
+
+let lastPreviewState = null;
+
+export function refreshOrderFromLastState() {
+ if (!_orderUpdateCallback || !lastPreviewState) return;
+ const { positions, cellSize, spacing, seriesCount } = lastPreviewState;
+ const cellRadius = cellSize / 2;
+ const busbarsNeeded = seriesCount + 1;
+
+ const busbarSheets = busbarStore.list.map(bb => {
+ if (!bb.cellIndices || bb.cellIndices.length === 0) {
+ return { name: bb.name, w: 0, h: 0, empty: true };
+ }
+ const pts = bb.cellIndices.map(i => positions[i]).filter(Boolean);
+ if (pts.length === 0) return { name: bb.name, w: 0, h: 0, empty: true };
+ const minX = Math.min(...pts.map(p => p[0])) - cellRadius - spacing;
+ const maxX = Math.max(...pts.map(p => p[0])) + cellRadius + spacing;
+ const minY = Math.min(...pts.map(p => p[1])) - cellRadius - spacing;
+ const maxY = Math.max(...pts.map(p => p[1])) + cellRadius + spacing;
+ return { name: bb.name, w: maxX - minX, h: maxY - minY, empty: false };
+ });
+
+ _orderUpdateCallback({ busbarSheets, busbarsNeeded });
+}
+
function getEdgeTabCenters(positions, cellRadius, spacing, layoutType) {
if (!Array.isArray(positions) || positions.length < 2) {
return { top: [], bottom: [] };
@@ -218,12 +247,7 @@ function attachEdgeTabsToNearestBusbars(busbars, geometries, positions, options)
const anchorPoint = best.anchor.slice();
const edgePoint = [best.tab.x, best.tab.y];
const innerPoint = [best.tab.x, best.tab.y + inwardDirection * overlapLength];
-
- const busbar = busbars[best.busbarIndex];
- const overlapOutward = Number(busbar.overlapSize) > 0 ? Number(busbar.overlapSize) : 0;
- const outerPoint = overlapOutward > 0
- ? [best.tab.x, best.tab.y - inwardDirection * overlapOutward]
- : null;
+ const outerPoint = [best.tab.x, best.tab.y - inwardDirection * overlapLength];
best.geometry.extraPads = Array.isArray(best.geometry.extraPads)
? best.geometry.extraPads
@@ -243,13 +267,11 @@ function attachEdgeTabsToNearestBusbars(busbars, geometries, positions, options)
pos: innerPoint,
radius: connectorRadius,
});
- if (outerPoint) {
- best.geometry.extraPads.push({
- key: `bms_tab_outer_${tabOverlapSide}_${best.tabKey}`,
- pos: outerPoint,
- radius: connectorRadius,
- });
- }
+ best.geometry.extraPads.push({
+ key: `bms_tab_outer_${tabOverlapSide}_${best.tabKey}`,
+ pos: outerPoint,
+ radius: connectorRadius,
+ });
best.geometry.extraSegments = Array.isArray(best.geometry.extraSegments)
? best.geometry.extraSegments
: [];
@@ -267,15 +289,13 @@ function attachEdgeTabsToNearestBusbars(busbars, geometries, positions, options)
toKey: `bms_tab_inner_${tabOverlapSide}_${best.tabKey}`,
radius: connectorRadius,
});
- if (outerPoint) {
- best.geometry.extraSegments.push({
- from: edgePoint,
- to: outerPoint,
- fromKey: `bms_tab_edge_${tabOverlapSide}_${best.tabKey}`,
- toKey: `bms_tab_outer_${tabOverlapSide}_${best.tabKey}`,
- radius: connectorRadius,
- });
- }
+ best.geometry.extraSegments.push({
+ from: edgePoint,
+ to: outerPoint,
+ fromKey: `bms_tab_edge_${tabOverlapSide}_${best.tabKey}`,
+ toKey: `bms_tab_outer_${tabOverlapSide}_${best.tabKey}`,
+ radius: connectorRadius,
+ });
}
}
@@ -484,7 +504,7 @@ export function updatePreview(resetView = false) {
enabled: document.getElementById('bmsHolesType')?.value === 'tabs',
cellRadius,
spacing,
- tabWidth: parseFloat(document.getElementById('height')?.value) || 10.0,
+ tabWidth: (parseFloat(document.getElementById('tabWidth')?.value) || 4.0) - 1,
tabOverlapSide: document.getElementById('tabOverlapSide')?.value || 'off',
overlapLength: parseFloat(document.getElementById('height')?.value) || 10.0,
layoutType,
@@ -508,7 +528,11 @@ export function updatePreview(resetView = false) {
const actualHeight = maxY - minY + cellSize + spacing * 2;
if (positions.length >= 2) {
- stats.textContent = `${positions.length} cells • ${actualWidth.toFixed(0)}×${actualHeight.toFixed(0)} mm`;
+ const areaCm2 = (actualWidth * actualHeight / 100).toFixed(0);
+ stats.textContent = `${positions.length} cells • ${actualWidth.toFixed(0)}×${actualHeight.toFixed(0)} mm • ${areaCm2} cm²`;
+ const s = Math.max(1, Math.round(parseFloat(document.getElementById('series')?.value) || 1));
+ lastPreviewState = { positions, cellSize, spacing, seriesCount: s };
+ refreshOrderFromLastState();
}
} catch (error) {
console.error('Preview error:', error);
@@ -732,7 +756,7 @@ export async function generateLayout() {
}
}
- const tabWidth = parseFloat(document.getElementById('height').value) || 10.0;
+ const edgeCutWidth = parseFloat(document.getElementById('tabWidth')?.value) || 4.0;
const tabLength = parseFloat(document.getElementById('tabLength')?.value) || 10.0;
const tabOverlapSide = document.getElementById('tabOverlapSide')?.value || 'off';
@@ -740,7 +764,7 @@ export async function generateLayout() {
cellSize, spacing, height, terminalDiameter, terminalDepth,
coverThickness, roundedCorners, bmsHoles, ledgeWidth,
filletBms, circleHoleOffset, useTabs, useFullCircles, bmsHoleDiameter,
- tabWidth, tabLength, tabDepth: 1.0, tabOverlapSide, layoutType,
+ tabWidth: edgeCutWidth, tabLength, tabDepth: 1.0, tabOverlapSide, layoutType,
};
const holderShape = create3DModel(positions, config);
@@ -778,7 +802,7 @@ export async function generateLayout() {
enabled: bmsHolesType === 'tabs',
cellRadius,
spacing,
- tabWidth,
+ tabWidth: edgeCutWidth - 1,
tabOverlapSide,
overlapLength: height,
layoutType,
diff --git a/src/busbar-geometry.js b/src/busbar-geometry.js
index 3fa10a9..d49fa91 100644
--- a/src/busbar-geometry.js
+++ b/src/busbar-geometry.js
@@ -309,6 +309,21 @@ function computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapL
return { extraPads: [], extraSegments: [] };
}
+ // Skip overlap if it would create a hard 90° bend at every boundary cell.
+ // This happens when no boundary cell has any busbar neighbour in the same row
+ // (i.e. the busbar only spans one column and all its internal connections are
+ // vertical). In that case the horizontal arm is perpendicular to every
+ // connection and would overlap adjacent busbars.
+ const hasSameRowBusbarNeighbour = boundaryEntries.some((entry) =>
+ selected.some((other) =>
+ other.index !== entry.index &&
+ Math.abs(other.pos[1] - entry.pos[1]) <= yTolerance
+ )
+ );
+ if (!hasSameRowBusbarNeighbour) {
+ return { extraPads: [], extraSegments: [] };
+ }
+
const extension = Number.isFinite(Number(overlapLength)) && Number(overlapLength) > 0
? Number(overlapLength)
: 10;
diff --git a/src/busbar-preview.js b/src/busbar-preview.js
index 8530d22..bced8aa 100644
--- a/src/busbar-preview.js
+++ b/src/busbar-preview.js
@@ -178,6 +178,54 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
}
}
+ // 3b. Fill four-cell square interstice (grid layout).
+ // For every diagonal pair (in triAdj but not adjCaps) that has exactly
+ // 2 common direct (capsule) neighbours, those 4 cells form a 2×2 square.
+ // Fill a circle at the centroid whose radius spans the gap.
+ {
+ const directSet = new Set(adjCaps.map(([a, b]) => `${Math.min(a,b)}_${Math.max(a,b)}`));
+ const visited4 = new Set();
+ off.fillStyle = opaqueColor;
+
+ for (let a = 0; a < nCells; a++) {
+ for (const b of triAdj[a]) {
+ if (b <= a) continue;
+ if (directSet.has(`${Math.min(a,b)}_${Math.max(a,b)}`)) continue; // skip direct neighbours
+
+ // a–b is diagonal; find their common direct neighbours
+ const common = [];
+ for (const c of triAdj[a]) {
+ if (c === b) continue;
+ if (!directSet.has(`${Math.min(a,c)}_${Math.max(a,c)}`)) continue;
+ if (!triAdj[b].has(c)) continue;
+ if (!directSet.has(`${Math.min(b,c)}_${Math.max(b,c)}`)) continue;
+ common.push(c);
+ }
+ if (common.length < 2) continue;
+
+ const [c, d] = common.sort((x, y) => x - y);
+ const quadKey = [a, b, c, d].sort((x, y) => x - y).join('_');
+ if (visited4.has(quadKey)) continue;
+ visited4.add(quadKey);
+
+ const pa = positions[cellIndices[a]];
+ const pb = positions[cellIndices[b]];
+ const pc = positions[cellIndices[c]];
+ const pd = positions[cellIndices[d]];
+ if (!pa || !pb || !pc || !pd) continue;
+
+ const qcx = (pa[0] + pb[0] + pc[0] + pd[0]) / 4;
+ const qcy = (pa[1] + pb[1] + pc[1] + pd[1]) / 4;
+ const distToCell = Math.hypot(pa[0] - qcx, pa[1] - qcy);
+ const fillR = Math.max(0.5, distToCell - padRadius);
+
+ off.beginPath();
+ off.arc(toScreenX(qcx), toScreenY(qcy), fillR * t.scale, 0, Math.PI * 2);
+ off.fill();
+ }
+ }
+ }
+
// 4. Obstacle-avoidance detour waypoints (spanning-tree edges with bends).
off.strokeStyle = opaqueColor;
off.lineWidth = 2 * padRadiusScreen();
diff --git a/src/main.js b/src/main.js
index e2e5898..77bd311 100644
--- a/src/main.js
+++ b/src/main.js
@@ -2,10 +2,11 @@ import { canvasState } from './state.js';
import { initOC } from './oc.js';
import { toggleBmsDiameter, initCustomSelects, showStatus } from './ui.js';
import { drawPreview } from './preview.js';
-import { updatePreview, generateLayout, redrawBusbarOverlay, downloadSingleBusbar, downloadAllBusbarsZip } from './app.js';
+import { updatePreview, generateLayout, redrawBusbarOverlay, downloadSingleBusbar, downloadAllBusbarsZip, setOrderUpdateCallback, refreshOrderFromLastState } from './app.js';
import { busbarStore } from './busbars.js';
import { initBusbarUI, renderBusbarList } from './busbar-ui.js';
import { captureConfig, encodeConfigToHash, decodeHashToConfig } from './url-config.js';
+import { renderOrderSection } from './order.js';
const CLICK_PIXEL_THRESHOLD = 4;
const URL_SYNC_DEBOUNCE_MS = 250;
@@ -627,6 +628,8 @@ function wireSidebarTabs() {
async function initializeApp() {
scaleCanvasForDPI();
initCustomSelects();
+ setOrderUpdateCallback(renderOrderSection);
+ busbarStore.subscribe(() => refreshOrderFromLastState());
wireSidebarTabs();
wirePackMode();
diff --git a/src/order.js b/src/order.js
new file mode 100644
index 0000000..1a9efb1
--- /dev/null
+++ b/src/order.js
@@ -0,0 +1,124 @@
+const WIDTHS = [50, 100, 150, 200, 300];
+const LENGTHS = [300, 1000, 1500];
+
+function calcCuts(sheetW, sheetL, busbarW, busbarH) {
+ if (busbarW <= 0 || busbarH <= 0) return 0;
+ const orientA = Math.floor(sheetW / busbarW) * Math.floor(sheetL / busbarH);
+ const orientB = Math.floor(sheetW / busbarH) * Math.floor(sheetL / busbarW);
+ return Math.max(orientA, orientB);
+}
+
+export function renderOrderSection({ busbarSheets, busbarsNeeded }) {
+ const container = document.getElementById('orderContent');
+ if (!container) return;
+
+ const defined = busbarSheets.length;
+ const nonEmpty = busbarSheets.filter(b => !b.empty);
+ const emptyOnes = busbarSheets.filter(b => b.empty);
+ const missing = busbarsNeeded - defined; // negative = too many defined, 0 = exact, positive = missing
+
+ // ── Warnings ─────────────────────────────────────────────────────────────
+ let warnings = '';
+ if (defined === 0) {
+ warnings += `No busbars defined. Add busbars in the list above and assign cells by clicking them in the preview.
`;
+ } else {
+ if (missing > 0) {
+ warnings += `${missing} busbar${missing > 1 ? 's' : ''} missing — need ${busbarsNeeded}, have ${defined}.
`;
+ }
+ if (emptyOnes.length > 0) {
+ const names = emptyOnes.map(b => `${escHtml(b.name)}`).join(', ');
+ warnings += `${names} ${emptyOnes.length === 1 ? 'has' : 'have'} no cells assigned — click cells in the preview to assign them.
`;
+ }
+ }
+
+ // ── Nothing to calculate ─────────────────────────────────────────────────
+ if (nonEmpty.length === 0) {
+ container.innerHTML = warnings +
+ `Assign cells to busbars to calculate sheet requirements.
`;
+ return;
+ }
+
+ // ── Max sheet dimensions (largest busbar drives the order size) ───────────
+ const maxW = Math.max(...nonEmpty.map(b => b.w));
+ const maxH = Math.max(...nonEmpty.map(b => b.h));
+ const totalSheets = busbarsNeeded; // what we actually need to order
+ const totalAreaCm2 = (maxW * maxH * totalSheets / 100).toFixed(1);
+ const singleAreaCm2 = (maxW * maxH / 100).toFixed(1);
+
+ // ── Per-busbar size breakdown (only when sizes differ) ────────────────────
+ const sizesVary = nonEmpty.some(b => Math.abs(b.w - maxW) > 0.5 || Math.abs(b.h - maxH) > 0.5);
+ let perBusbarHtml = '';
+ if (sizesVary) {
+ perBusbarHtml = ``;
+ for (const b of nonEmpty) {
+ perBusbarHtml += `
+ ${escHtml(b.name)}
+ ${b.w.toFixed(0)} × ${b.h.toFixed(0)} mm (${(b.w * b.h / 100).toFixed(1)} cm²)
+
`;
+ }
+ perBusbarHtml += `
`;
+ }
+
+ // ── Sheet table ───────────────────────────────────────────────────────────
+ const rows = [];
+ for (const w of WIDTHS) {
+ for (const l of LENGTHS) {
+ const cuts = calcCuts(w, l, maxW, maxH);
+ const sheets = cuts > 0 ? Math.ceil(totalSheets / cuts) : null;
+ rows.push({ w, l, cuts, sheets });
+ }
+ }
+ const fittingSheets = rows.filter(r => r.sheets !== null).map(r => r.sheets);
+ const bestSheets = fittingSheets.length > 0 ? Math.min(...fittingSheets) : null;
+
+ let tableHtml = `
+
+
+
+ | Sheet (W×L mm) |
+ Cuts / sheet |
+ Sheets to buy |
+
+
+
+ `;
+ for (const { w, l, cuts, sheets } of rows) {
+ const noFit = cuts === 0;
+ const isBest = !noFit && sheets === bestSheets;
+ const cls = noFit ? 'order-row-nofit' : (isBest ? 'order-row-best' : '');
+ tableHtml += `
+
+ | ${w} × ${l} |
+ ${noFit ? '—' : cuts} |
+ ${noFit ? '—' : sheets} |
+
+ `;
+ }
+ tableHtml += `
`;
+
+ // ── Summary ───────────────────────────────────────────────────────────────
+ const summaryHtml = `
+
+
+ Largest busbar
+ ${maxW.toFixed(0)} × ${maxH.toFixed(0)} mm
+
+
+ Sheets needed
+ ${totalSheets}
+
+
+ Total copper area
+ ${totalAreaCm2} cm² (${singleAreaCm2} cm² each)
+
+
+ `;
+
+ container.innerHTML = warnings + summaryHtml + perBusbarHtml + tableHtml;
+}
+
+function escHtml(s) {
+ return String(s).replace(/[&<>"']/g, ch => (
+ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]
+ ));
+}
diff --git a/src/preview.js b/src/preview.js
index abab840..6af0868 100644
--- a/src/preview.js
+++ b/src/preview.js
@@ -384,7 +384,7 @@ export function drawPreview(positions, cellSize) {
ctx.strokeStyle = 'rgba(255, 193, 7, 0.9)';
ctx.lineWidth = 1.5 / zoom;
- const tabWidthMm = parseFloat(document.getElementById('height').value) || 10.0;
+ const tabWidthMm = parseFloat(document.getElementById('tabWidth')?.value) || 4.0;
const tabWidth = tabWidthMm * scale;
const tabHeight = 1.0 * scale;
diff --git a/styles/main.css b/styles/main.css
index c5d76c9..78408a5 100644
--- a/styles/main.css
+++ b/styles/main.css
@@ -1198,6 +1198,143 @@ input[type="checkbox"]:focus {
}
}
+/* ── Copper Sheet Order Calculator ─────────────────────────────────────── */
+
+.order-header {
+ font-size: 0.72em;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: #64748b;
+ padding: 4px 0 10px;
+ margin-top: 4px;
+}
+
+.order-placeholder {
+ font-size: 0.85em;
+ color: #475569;
+ padding: 4px 0 8px;
+}
+
+.order-summary {
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid rgba(100, 149, 237, 0.1);
+ border-radius: 8px;
+ padding: 10px 12px;
+ margin-bottom: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.order-summary-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+ font-size: 0.85em;
+}
+
+.order-label {
+ color: #64748b;
+ white-space: nowrap;
+}
+
+.order-value {
+ color: #e2e8f0;
+ font-weight: 600;
+ text-align: right;
+}
+
+.order-muted {
+ color: #64748b;
+ font-weight: 400;
+ font-size: 0.9em;
+}
+
+.order-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.83em;
+ margin-bottom: 4px;
+}
+
+.order-table th {
+ text-align: left;
+ padding: 5px 8px;
+ color: #64748b;
+ font-weight: 600;
+ font-size: 0.9em;
+ border-bottom: 1px solid rgba(100, 149, 237, 0.15);
+ white-space: nowrap;
+}
+
+.order-table td {
+ padding: 5px 8px;
+ color: #cbd5e1;
+ border-bottom: 1px solid rgba(100, 149, 237, 0.06);
+ white-space: nowrap;
+}
+
+.order-table tr:last-child td {
+ border-bottom: none;
+}
+
+.order-row-nofit td {
+ color: #334155;
+}
+
+.order-row-best td {
+ color: #fff;
+ font-weight: 600;
+}
+
+.order-row-best td:last-child {
+ color: #6ee7b7;
+}
+
+.order-table tr:not(.order-row-nofit):not(.order-row-best):hover td {
+ background: rgba(100, 149, 237, 0.07);
+ color: #e2e8f0;
+}
+
+.order-warning {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+ font-size: 0.82em;
+ color: #fbbf24;
+ background: rgba(251, 191, 36, 0.08);
+ border: 1px solid rgba(251, 191, 36, 0.25);
+ border-radius: 6px;
+ padding: 7px 10px;
+ margin-bottom: 8px;
+ line-height: 1.4;
+}
+
+.order-warning strong {
+ color: #fde68a;
+}
+
+.order-busbar-sizes {
+ background: rgba(0, 0, 0, 0.15);
+ border: 1px solid rgba(100, 149, 237, 0.08);
+ border-radius: 6px;
+ padding: 7px 12px;
+ margin-bottom: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.order-busbar-size-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+ font-size: 0.82em;
+}
+
/* 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 {