Fixed the edge tabs overhang

This commit is contained in:
Finn Tews
2026-05-08 21:09:06 +02:00
parent ae2e631cc7
commit feac4235b2
10 changed files with 393 additions and 36 deletions

View File

@@ -157,6 +157,10 @@
<label>Tab Length (mm)</label>
<input type="number" id="tabLength" value="10.0" min="1" step="0.5">
</div>
<div class="form-group">
<label>Edge Cut Width (mm)</label>
<input type="number" id="tabWidth" value="4.0" min="1" step="0.5">
</div>
</div>
<div class="form-group" id="tabOverlapSideGroup" style="display:none;">
<label>Tab Overlap Side</label>
@@ -200,6 +204,11 @@
<button class="btn-secondary" id="addTopBusbarBtn">+ Top Busbar</button>
<button class="btn-secondary" id="addBottomBusbarBtn">+ Bottom Busbar</button>
</div>
<div class="panel-divider" aria-hidden="true"></div>
<div class="order-header">Copper Sheet Calculator</div>
<div id="orderContent">
<p class="order-placeholder">Generate a preview first to see sheet requirements.</p>
</div>
</section>
</div>

View File

@@ -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
}
},

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@@ -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.extraSegments = Array.isArray(best.geometry.extraSegments)
? best.geometry.extraSegments
: [];
@@ -267,7 +289,6 @@ 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,
@@ -277,7 +298,6 @@ function attachEdgeTabsToNearestBusbars(busbars, geometries, positions, options)
});
}
}
}
function drawBothCanvases(positions, cellSize, padRadius, spacing) {
drawPreview(positions, cellSize);
@@ -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,

View File

@@ -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;

View File

@@ -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
// ab 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();

View File

@@ -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();

124
src/order.js Normal file
View File

@@ -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 += `<div class="order-warning">No busbars defined. Add busbars in the list above and assign cells by clicking them in the preview.</div>`;
} else {
if (missing > 0) {
warnings += `<div class="order-warning">${missing} busbar${missing > 1 ? 's' : ''} missing &mdash; need ${busbarsNeeded}, have ${defined}.</div>`;
}
if (emptyOnes.length > 0) {
const names = emptyOnes.map(b => `<strong>${escHtml(b.name)}</strong>`).join(', ');
warnings += `<div class="order-warning">${names} ${emptyOnes.length === 1 ? 'has' : 'have'} no cells assigned &mdash; click cells in the preview to assign them.</div>`;
}
}
// ── Nothing to calculate ─────────────────────────────────────────────────
if (nonEmpty.length === 0) {
container.innerHTML = warnings +
`<p class="order-placeholder">Assign cells to busbars to calculate sheet requirements.</p>`;
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 = `<div class="order-busbar-sizes">`;
for (const b of nonEmpty) {
perBusbarHtml += `<div class="order-busbar-size-row">
<span class="order-label">${escHtml(b.name)}</span>
<span class="order-value">${b.w.toFixed(0)} &times; ${b.h.toFixed(0)} mm &nbsp;<span class="order-muted">(${(b.w * b.h / 100).toFixed(1)} cm²)</span></span>
</div>`;
}
perBusbarHtml += `</div>`;
}
// ── 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 = `
<table class="order-table">
<thead>
<tr>
<th>Sheet (W&times;L mm)</th>
<th>Cuts / sheet</th>
<th>Sheets to buy</th>
</tr>
</thead>
<tbody>
`;
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 += `
<tr class="${cls}">
<td>${w} &times; ${l}</td>
<td>${noFit ? '&mdash;' : cuts}</td>
<td>${noFit ? '&mdash;' : sheets}</td>
</tr>
`;
}
tableHtml += `</tbody></table>`;
// ── Summary ───────────────────────────────────────────────────────────────
const summaryHtml = `
<div class="order-summary">
<div class="order-summary-row">
<span class="order-label">Largest busbar</span>
<span class="order-value">${maxW.toFixed(0)} &times; ${maxH.toFixed(0)} mm</span>
</div>
<div class="order-summary-row">
<span class="order-label">Sheets needed</span>
<span class="order-value">${totalSheets}</span>
</div>
<div class="order-summary-row">
<span class="order-label">Total copper area</span>
<span class="order-value">${totalAreaCm2} cm² <span class="order-muted">(${singleAreaCm2} cm² each)</span></span>
</div>
</div>
`;
container.innerHTML = warnings + summaryHtml + perBusbarHtml + tableHtml;
}
function escHtml(s) {
return String(s).replace(/[&<>"']/g, ch => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]
));
}

View File

@@ -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;

View File

@@ -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 {