From feac4235b2e790f02e96ccde49ba8e4bff0e0696 Mon Sep 17 00:00:00 2001 From: Finn Tews Date: Fri, 8 May 2026 21:09:06 +0200 Subject: [PATCH] Fixed the edge tabs overhang --- index.html | 9 ++ node_modules/.vite/deps/_metadata.json | 8 +- .../.vite/deps_temp_6e38d81b/package.json | 3 - src/app.js | 78 ++++++---- src/busbar-geometry.js | 15 ++ src/busbar-preview.js | 48 ++++++ src/main.js | 5 +- src/order.js | 124 ++++++++++++++++ src/preview.js | 2 +- styles/main.css | 137 ++++++++++++++++++ 10 files changed, 393 insertions(+), 36 deletions(-) delete mode 100644 node_modules/.vite/deps_temp_6e38d81b/package.json create mode 100644 src/order.js diff --git a/index.html b/index.html index 351740d..d4601b7 100644 --- a/index.html +++ b/index.html @@ -157,6 +157,10 @@ +
+ + +
+ +
Copper Sheet Calculator
+
+

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 = ` + + + + + + + + + + `; + 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 += ` + + + + + + `; + } + tableHtml += `
Sheet (W×L mm)Cuts / sheetSheets to buy
${w} × ${l}${noFit ? '—' : cuts}${noFit ? '—' : sheets}
`; + + // ── 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 {