added edge tabs and a lot of bug fixes abizt overlaps

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Finn Tews
2026-05-07 08:39:18 +02:00
parent 80e5c17ead
commit ae2e631cc7
26 changed files with 1213 additions and 162 deletions

View File

@@ -23,6 +23,262 @@ export function getLastBusbarGeometries() {
return lastComputedGeometries;
}
function getEdgeTabCenters(positions, cellRadius, spacing, layoutType) {
if (!Array.isArray(positions) || positions.length < 2) {
return { top: [], bottom: [] };
}
const rows = new Map();
for (const [x, y] of positions) {
const key = y.toFixed(4);
if (!rows.has(key)) rows.set(key, []);
rows.get(key).push([x, y]);
}
const rowKeys = Array.from(rows.keys()).sort((a, b) => Number(a) - Number(b));
if (rowKeys.length === 0) return { top: [], bottom: [] };
const topRow = (rows.get(rowKeys[0]) || []).slice().sort((a, b) => a[0] - b[0]);
const bottomRow = (rows.get(rowKeys[rowKeys.length - 1]) || []).slice().sort((a, b) => a[0] - b[0]);
const topY = Math.min(...positions.map(([, y]) => y)) - cellRadius - spacing;
const bottomY = Math.max(...positions.map(([, y]) => y)) + cellRadius + spacing;
const minAllX = Math.min(...positions.map(([x]) => x));
const topMidpoints = topRow.slice(0, -1).map((cell, index) => ({
key: `top_${index}`,
x: (cell[0] + topRow[index + 1][0]) / 2,
y: topY,
}));
const bottomMidpoints = bottomRow.slice(0, -1).map((cell, index) => ({
key: `bottom_${index}`,
x: (cell[0] + bottomRow[index + 1][0]) / 2,
y: bottomY,
}));
// Grid: no extra tab on either edge
if (layoutType === 'grid') {
return { top: topMidpoints, bottom: bottomMidpoints };
}
// Vertical honeycomb: column pitch = min X delta between any two cells
if (layoutType === 'vertical') {
const allXSorted = [...new Set(positions.map(([x]) => Math.round(x * 1000)))]
.sort((a, b) => a - b).map(v => v / 1000);
const colPitch = allXSorted.length >= 2 ? allXSorted[1] - allXSorted[0] : 0;
// Even-column rows start at minAllX; odd-column rows are offset by colPitch
const topIsEven = colPitch === 0 || (topRow[0][0] - minAllX) < colPitch / 2;
const bottomIsEven = colPitch === 0 || (bottomRow[0][0] - minAllX) < colPitch / 2;
return {
top: topIsEven
? topMidpoints
: [{ key: 'top_extra_left', x: topRow[0][0] - colPitch / 2, y: topY },
...topMidpoints,
{ key: 'top_extra_right', x: topRow[topRow.length - 1][0] + colPitch / 2, y: topY }],
bottom: bottomIsEven
? bottomMidpoints
: [{ key: 'bottom_extra_left', x: bottomRow[0][0] - colPitch / 2, y: bottomY },
...bottomMidpoints,
{ key: 'bottom_extra_right', x: bottomRow[bottomRow.length - 1][0] + colPitch / 2, y: bottomY }],
};
}
// Horizontal honeycomb: 1 extra tab on the side that has a gap to the wall
const topPitch = topRow.length >= 2 ? topRow[topRow.length - 1][0] - topRow[topRow.length - 2][0] : 0;
const bottomPitch = bottomRow.length >= 2 ? bottomRow[bottomRow.length - 1][0] - bottomRow[bottomRow.length - 2][0] : 0;
const topExtraRight = topRow.length < 2 || (topRow[0][0] - minAllX) < topPitch / 4;
const bottomExtraRight = bottomRow.length < 2 || (bottomRow[0][0] - minAllX) < bottomPitch / 4;
return {
top: topExtraRight
? [...topMidpoints, { key: 'top_extra', x: topRow[topRow.length - 1][0] + topPitch / 2, y: topY }]
: [{ key: 'top_extra', x: topRow[0][0] - topPitch / 2, y: topY }, ...topMidpoints],
bottom: bottomExtraRight
? [...bottomMidpoints, { key: 'bottom_extra', x: bottomRow[bottomRow.length - 1][0] + bottomPitch / 2, y: bottomY }]
: [{ key: 'bottom_extra', x: bottomRow[0][0] - bottomPitch / 2, y: bottomY }, ...bottomMidpoints],
};
}
function buildBusbarAnchorCandidates(geometry, positions) {
const candidates = [];
const seen = new Set();
const addPoint = (x, y) => {
const key = `${x.toFixed(4)},${y.toFixed(4)}`;
if (seen.has(key)) return;
seen.add(key);
candidates.push([x, y]);
};
for (const index of geometry?.padIndices || []) {
const point = positions[index];
if (!point) continue;
addPoint(point[0], point[1]);
}
for (const edge of geometry?.edges || []) {
const stops = [positions[edge.from], ...(edge.waypoints || []), positions[edge.to]].filter(Boolean);
for (let i = 0; i < stops.length - 1; i++) {
const a = stops[i];
const b = stops[i + 1];
addPoint((a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
}
}
for (const pad of geometry?.extraPads || []) {
if (!Array.isArray(pad?.pos)) continue;
addPoint(pad.pos[0], pad.pos[1]);
}
for (const segment of geometry?.extraSegments || []) {
if (!Array.isArray(segment?.from) || !Array.isArray(segment?.to)) continue;
addPoint(segment.from[0], segment.from[1]);
addPoint(segment.to[0], segment.to[1]);
addPoint((segment.from[0] + segment.to[0]) / 2, (segment.from[1] + segment.to[1]) / 2);
}
return candidates;
}
function attachEdgeTabsToNearestBusbars(busbars, geometries, positions, options) {
const {
enabled = false,
cellRadius,
spacing,
tabWidth,
tabOverlapSide,
overlapLength = 28,
layoutType = 'honeycomb',
} = options;
if (enabled !== true) return;
if (tabOverlapSide !== 'top' && tabOverlapSide !== 'bottom') return;
const tabCenters = getEdgeTabCenters(positions, cellRadius, spacing, layoutType)[tabOverlapSide];
if (tabCenters.length === 0) return;
const inwardDirection = tabOverlapSide === 'top' ? 1 : -1;
const maxHorizontalGap = Math.max((tabWidth || 0), cellRadius * 2 + spacing * 2);
const maxVerticalGap = overlapLength + cellRadius * 2 + spacing * 2;
const betweenBusbarThreshold = Math.max((tabWidth || 0) * 0.6, cellRadius * 0.75);
const connectorRadius = Math.max(0.05, ((tabWidth || 0) - 0.5) / 2);
const selectedByBusbar = [];
for (let i = 0; i < busbars.length; i++) {
const busbar = busbars[i];
const geometry = geometries[i];
if (!geometry || geometry.blocked) continue;
const anchors = buildBusbarAnchorCandidates(geometry, positions);
if (anchors.length === 0) continue;
const extremeY = tabOverlapSide === 'top'
? Math.min(...anchors.map(([, y]) => y))
: Math.max(...anchors.map(([, y]) => y));
const edgeBand = Math.max(cellRadius * 1.1, spacing + cellRadius * 0.35);
const edgeAnchors = anchors.filter(([, y]) => (
tabOverlapSide === 'top' ? y <= extremeY + edgeBand : y >= extremeY - edgeBand
));
if (edgeAnchors.length === 0) continue;
let best = null;
for (const tab of tabCenters) {
for (const anchor of edgeAnchors) {
const dx = Math.abs(anchor[0] - tab.x);
const dy = Math.abs(tab.y - anchor[1]);
if (dx > maxHorizontalGap || dy > maxVerticalGap) continue;
const score = dx * 3 + dy;
const candidate = {
busbarIndex: i,
geometry,
anchor,
score,
tabKey: tab.key,
tab,
deltaX: anchor[0] - tab.x,
};
if (!best || candidate.score < best.score) {
best = candidate;
}
}
}
if (best) selectedByBusbar.push(best);
}
const conflictsByTab = new Map();
for (const candidate of selectedByBusbar) {
if (!conflictsByTab.has(candidate.tabKey)) conflictsByTab.set(candidate.tabKey, []);
conflictsByTab.get(candidate.tabKey).push(candidate);
}
for (const best of selectedByBusbar) {
const sameTabCandidates = conflictsByTab.get(best.tabKey) || [];
const leftCandidate = sameTabCandidates.find((candidate) => candidate.deltaX < -betweenBusbarThreshold);
const rightCandidate = sameTabCandidates.find((candidate) => candidate.deltaX > betweenBusbarThreshold);
if (leftCandidate && rightCandidate) continue;
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;
best.geometry.extraPads = Array.isArray(best.geometry.extraPads)
? best.geometry.extraPads
: [];
best.geometry.extraPads.push({
key: `bms_tab_anchor_${tabOverlapSide}_${best.tabKey}`,
pos: anchorPoint,
radius: connectorRadius,
});
best.geometry.extraPads.push({
key: `bms_tab_edge_${tabOverlapSide}_${best.tabKey}`,
pos: edgePoint,
radius: connectorRadius,
});
best.geometry.extraPads.push({
key: `bms_tab_inner_${tabOverlapSide}_${best.tabKey}`,
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
: [];
best.geometry.extraSegments.push({
from: anchorPoint,
to: innerPoint,
fromKey: `bms_tab_anchor_${tabOverlapSide}_${best.tabKey}`,
toKey: `bms_tab_inner_${tabOverlapSide}_${best.tabKey}`,
radius: connectorRadius,
});
best.geometry.extraSegments.push({
from: edgePoint,
to: innerPoint,
fromKey: `bms_tab_edge_${tabOverlapSide}_${best.tabKey}`,
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,
});
}
}
}
function drawBothCanvases(positions, cellSize, padRadius, spacing) {
drawPreview(positions, cellSize);
@@ -202,9 +458,37 @@ export function updatePreview(resetView = false) {
const cellRadius = cellSize / 2;
const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0);
const busbarKeepoutRadius = 4.0;
const busbarCellCutoutEnabled = document.getElementById('busbarCellCutoutEnabled')?.checked === true;
const packBounds = {
left: Math.min(...positions.map(p => p[0])) - cellRadius - spacing,
right: Math.max(...positions.map(p => p[0])) + cellRadius + spacing,
bottom: Math.min(...positions.map(p => p[1])) - cellRadius - spacing,
top: Math.max(...positions.map(p => p[1])) + cellRadius + spacing,
};
lastComputedGeometries = busbarStore.list.map(bb =>
computeBusbarGeometry(bb.cellIndices, positions, cellRadius, busbarPadRadius, spacing, busbarKeepoutRadius)
computeBusbarGeometry(
bb.cellIndices,
positions,
cellRadius,
busbarPadRadius,
spacing,
busbarKeepoutRadius,
packBounds,
bb.overlapEnabled !== false,
layoutType,
bb.overlapSize,
busbarCellCutoutEnabled,
)
);
attachEdgeTabsToNearestBusbars(busbarStore.list, lastComputedGeometries, positions, {
enabled: document.getElementById('bmsHolesType')?.value === 'tabs',
cellRadius,
spacing,
tabWidth: parseFloat(document.getElementById('height')?.value) || 10.0,
tabOverlapSide: document.getElementById('tabOverlapSide')?.value || 'off',
overlapLength: parseFloat(document.getElementById('height')?.value) || 10.0,
layoutType,
});
lastBusbarDrawArgs = { positions, cellSize, padRadius: busbarPadRadius, spacing };
drawBothCanvases(positions, cellSize, busbarPadRadius, spacing);
@@ -448,14 +732,15 @@ export async function generateLayout() {
}
}
const tabWidth = parseFloat(document.getElementById('tabWidth').value) || 4.0;
const tabDepth = parseFloat(document.getElementById('tabDepth').value) || 1.0;
const tabWidth = parseFloat(document.getElementById('height').value) || 10.0;
const tabLength = parseFloat(document.getElementById('tabLength')?.value) || 10.0;
const tabOverlapSide = document.getElementById('tabOverlapSide')?.value || 'off';
const config = {
cellSize, spacing, height, terminalDiameter, terminalDepth,
coverThickness, roundedCorners, bmsHoles, ledgeWidth,
filletBms, circleHoleOffset, useTabs, useFullCircles, bmsHoleDiameter,
tabWidth, tabDepth,
tabWidth, tabLength, tabDepth: 1.0, tabOverlapSide, layoutType,
};
const holderShape = create3DModel(positions, config);
@@ -467,9 +752,37 @@ export async function generateLayout() {
const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0);
const busbarKeepoutRadius = terminalDiameter / 2;
const busbarCellCutoutEnabled = document.getElementById('busbarCellCutoutEnabled')?.checked === true;
const packBounds = {
left: Math.min(...positions.map(p => p[0])) - cellRadius - spacing,
right: Math.max(...positions.map(p => p[0])) + cellRadius + spacing,
bottom: Math.min(...positions.map(p => p[1])) - cellRadius - spacing,
top: Math.max(...positions.map(p => p[1])) + cellRadius + spacing,
};
const busbarGeometries = busbarStore.list.map(bb =>
computeBusbarGeometry(bb.cellIndices, positions, cellRadius, busbarPadRadius, spacing, busbarKeepoutRadius)
computeBusbarGeometry(
bb.cellIndices,
positions,
cellRadius,
busbarPadRadius,
spacing,
busbarKeepoutRadius,
packBounds,
bb.overlapEnabled !== false,
layoutType,
bb.overlapSize,
busbarCellCutoutEnabled,
)
);
attachEdgeTabsToNearestBusbars(busbarStore.list, busbarGeometries, positions, {
enabled: bmsHolesType === 'tabs',
cellRadius,
spacing,
tabWidth,
tabOverlapSide,
overlapLength: height,
layoutType,
});
for (let i = 0; i < busbarStore.list.length; i++) {
const bb = busbarStore.list[i];
@@ -492,10 +805,18 @@ export async function generateLayout() {
// 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 busbarSignature = (bb, geom) => {
const idxs = bb.cellIndices;
const cellCutoutEnabled = document.getElementById('busbarCellCutoutEnabled')?.checked === true;
const tabSegments = (Array.isArray(geom?.extraSegments) ? geom.extraSegments : [])
.filter((segment) => String(segment?.fromKey || '').startsWith('bms_tab_') || String(segment?.toKey || '').startsWith('bms_tab_'))
.map((segment) => `${segment.from[0].toFixed(3)},${segment.from[1].toFixed(3)}>${segment.to[0].toFixed(3)},${segment.to[1].toFixed(3)}`)
.sort()
.join(';');
if (idxs.length === 0) return null;
if (idxs.length === 1) return `single|${bb.thickness.toFixed(2)}`;
if (idxs.length === 1) {
return `single|${bb.thickness.toFixed(2)}|ov:${bb.overlapEnabled === true ? 1 : 0}|os:${Number(bb.overlapSize ?? 10).toFixed(2)}|cc:${cellCutoutEnabled ? 1 : 0}|tabs:${tabSegments}`;
}
const pts = idxs.map(i => centeredPositions[i]).filter(Boolean);
const dists = [];
for (let a = 0; a < pts.length; a++) {
@@ -504,7 +825,7 @@ export async function generateLayout() {
}
}
dists.sort((x, y) => x - y);
return `${pts.length}|${bb.thickness.toFixed(2)}|${dists.map(d => d.toFixed(3)).join(',')}`;
return `${pts.length}|${bb.thickness.toFixed(2)}|ov:${bb.overlapEnabled === true ? 1 : 0}|os:${Number(bb.overlapSize ?? 10).toFixed(2)}|cc:${cellCutoutEnabled ? 1 : 0}|tabs:${tabSegments}|${dists.map(d => d.toFixed(3)).join(',')}`;
};
// Export format for busbars: STEP solid or DXF flat pattern. The cellholder
@@ -518,14 +839,14 @@ export async function generateLayout() {
for (let i = 0; i < busbarStore.list.length; i++) {
const bb = busbarStore.list[i];
if (bb.cellIndices.length === 0) continue;
const sig = busbarSignature(bb);
const sig = busbarSignature(bb, busbarGeometries[i]);
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);
entry.shape = build3DBusbar(centeredGeom(busbarGeometries[i], holderCenterX, holderCenterY), centeredPositions, busbarPadRadius, height, bb.thickness);
if (!entry.shape) continue;
}
uniqueBusbars.push(entry);
@@ -540,7 +861,7 @@ export async function generateLayout() {
const { bb, geom, shape } = uniqueBusbars[i];
const base = `busbar_${safeName(bb.name)}`;
if (busbarFormat === 'dxf') {
const content = buildBusbarDXF(geom, centeredPositions, busbarPadRadius);
const content = buildBusbarDXF(centeredGeom(geom, holderCenterX, holderCenterY), centeredPositions, busbarPadRadius);
downloadDXF(content, `${base}.dxf`);
} else {
downloadSTEP(shape, `${base}.step`);
@@ -570,6 +891,21 @@ export async function generateLayout() {
// ── Per-busbar download helpers ────────────────────────────────────────────────
// Return a copy of geom with extraPads, extraSegments, and cutouts shifted by (-cx, -cy).
function centeredGeom(geom, cx, cy) {
if (!geom) return geom;
const cutouts = Array.isArray(geom.cutouts)
? geom.cutouts.map(c => ({ ...c, center: [c.center[0] - cx, c.center[1] - cy] }))
: geom.cutouts;
const extraPads = Array.isArray(geom.extraPads)
? geom.extraPads.map(p => ({ ...p, pos: [p.pos[0] - cx, p.pos[1] - cy] }))
: geom.extraPads;
const extraSegments = Array.isArray(geom.extraSegments)
? geom.extraSegments.map(s => ({ ...s, from: [s.from[0] - cx, s.from[1] - cy], to: [s.to[0] - cx, s.to[1] - cy] }))
: geom.extraSegments;
return { ...geom, cutouts, extraPads, extraSegments };
}
function getBusbarExportContext() {
if (!lastBusbarDrawArgs || lastComputedGeometries.length === 0) return null;
const { positions, padRadius } = lastBusbarDrawArgs;
@@ -579,7 +915,7 @@ function getBusbarExportContext() {
const height = parseFloat(document.getElementById('height').value);
const busbarFormat = document.getElementById('busbarFormat')?.value || 'step';
const safeName = (name) => (name || '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'busbar';
return { centeredPositions, padRadius, height, busbarFormat, safeName };
return { centeredPositions, centerX: cx, centerY: cy, padRadius, height, busbarFormat, safeName };
}
export async function downloadSingleBusbar(busbarId) {
@@ -609,10 +945,10 @@ export async function downloadSingleBusbar(busbarId) {
await new Promise(r => setTimeout(r, 20));
try {
if (ctx.busbarFormat === 'dxf') {
const content = buildBusbarDXF(geom, ctx.centeredPositions, ctx.padRadius);
const content = buildBusbarDXF(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius);
downloadDXF(content, `${base}.dxf`);
} else {
const shape = build3DBusbar(geom, ctx.centeredPositions, ctx.padRadius, ctx.height, bb.thickness);
const shape = build3DBusbar(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius, ctx.height, bb.thickness);
if (!shape) { showStatus(`Failed to build 3D shape for ${bb.name}.`, 'error'); return; }
downloadSTEP(shape, `${base}.step`);
}
@@ -648,10 +984,10 @@ export async function downloadAllBusbarsZip() {
for (const { bb, geom } of eligible) {
const base = `busbar_${ctx.safeName(bb.name)}`;
if (ctx.busbarFormat === 'dxf') {
const content = buildBusbarDXF(geom, ctx.centeredPositions, ctx.padRadius);
const content = buildBusbarDXF(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius);
zip.file(`${base}.dxf`, content);
} else {
const shape = build3DBusbar(geom, ctx.centeredPositions, ctx.padRadius, ctx.height, bb.thickness);
const shape = build3DBusbar(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius, ctx.height, bb.thickness);
if (!shape) continue;
const bytes = buildSTEPBytes(shape, `_zip_${base}.step`);
if (bytes) zip.file(`${base}.step`, bytes);