Files
battery-builder/src/app.js
2026-05-08 21:09:06 +02:00

1037 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { canvasState } from './state.js';
import { showStatus, showLoading } from './ui.js';
import { ocRef, initOC } from './oc.js';
import {
generateGridLayout,
generateHoneycombLayout,
generateVerticalHoneycombLayout,
getCachedPositions,
} from './layouts.js';
import { drawPreview, clearCanvas, drawPreviewCopy, drawPreviewMirroredCopy } from './preview.js';
import { create3DModel } from './model.js';
import { downloadSTEP, buildSTEPBytes } 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';
import { build3DBusbar } from './busbar-model.js';
import { renderBusbarList } from './busbar-ui.js';
let lastComputedGeometries = [];
let lastBusbarDrawArgs = null;
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: [] };
}
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 outerPoint = [best.tab.x, best.tab.y - inwardDirection * overlapLength];
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,
});
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,
});
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);
const indexed = busbarStore.list.map((bb, i) => ({ bb, geom: lastComputedGeometries[i] }));
const topPairs = indexed.filter(p => (p.bb.face || 'top') === 'top');
const bottomPairs = indexed.filter(p => (p.bb.face || 'top') === 'bottom');
// Copy the clean pack layout BEFORE any busbar overlay is painted.
drawPreviewMirroredCopy('preview-bottom');
// Now draw each face's busbars on its own canvas only.
drawBusbarsOverlay(
topPairs.map(p => p.bb), topPairs.map(p => p.geom),
positions, cellSize, padRadius, spacing, busbarStore.activeId, 'preview'
);
drawBusbarsOverlay(
bottomPairs.map(p => p.bb), bottomPairs.map(p => p.geom),
positions, cellSize, padRadius, spacing, busbarStore.activeId, 'preview-bottom',
false, true
);
}
export function redrawBusbarOverlay() {
if (!lastBusbarDrawArgs) return;
const { positions, cellSize, padRadius, spacing } = lastBusbarDrawArgs;
drawBothCanvases(positions, cellSize, padRadius, spacing);
}
export function updatePreview(resetView = false) {
if (resetView) {
canvasState.zoom = 1.0;
canvasState.panX = 0;
canvasState.panY = 0;
}
const stats = document.getElementById('previewStats');
const setStats = (text, color) => {
stats.textContent = text;
stats.style.color = color;
};
try {
const xDim = parseFloat(document.getElementById('xDim').value);
const yDim = parseFloat(document.getElementById('yDim').value);
const spacing = parseFloat(document.getElementById('spacing').value);
const cellSize = parseFloat(document.getElementById('cellSize').value);
const layoutType = document.getElementById('layoutType').value;
const ledgeWidth = parseFloat(document.getElementById('ledgeWidth').value) || 0;
const bmsHoleDiameter = parseFloat(document.getElementById('bmsHoleDiameter').value) || 4.0;
const coverThickness = parseFloat(document.getElementById('coverThickness').value);
if (!xDim || !yDim || !spacing || !cellSize) {
setStats('Configure settings to see preview', '#94a3b8');
return;
}
if (ledgeWidth > 0 && ledgeWidth >= cellSize) {
setStats(`Ledge width (${ledgeWidth}mm) must be less than cell diameter (${cellSize}mm)!`, '#ef4444');
clearCanvas();
return;
}
const minPackSize = cellSize + spacing * 2;
if (xDim < minPackSize || yDim < minPackSize) {
setStats(`Pack too small! Minimum: ${minPackSize.toFixed(1)}×${minPackSize.toFixed(1)} mm`, '#ef4444');
clearCanvas();
return;
}
if (cellSize > xDim || cellSize > yDim) {
setStats(`Cell diameter (${cellSize}mm) larger than pack dimensions!`, '#ef4444');
clearCanvas();
return;
}
if (spacing < 0) {
setStats(`Cell spacing cannot be negative!`, '#ef4444');
clearCanvas();
return;
}
const positions = getCachedPositions(xDim, yDim, spacing, cellSize, layoutType);
if (!positions || positions.length === 0) {
setStats(`No cells fit! Increase pack size or decrease cell size/spacing`, '#ef4444');
clearCanvas();
return;
}
const bmsHolesType = document.getElementById('bmsHolesType').value;
const bmsHoles = bmsHolesType !== 'off';
if (bmsHoles) {
if (bmsHoleDiameter > cellSize) {
setStats(`BMS hole (${bmsHoleDiameter}mm) larger than cell (${cellSize}mm)! Reduce hole size.`, '#ef4444');
clearCanvas();
return;
}
const cellRadius = cellSize / 2;
const bmsHoleRadius = bmsHoleDiameter / 2;
const r = cellRadius;
const minY = Math.min(...positions.map(p => p[1]));
const maxY = Math.max(...positions.map(p => p[1]));
const packMaxY = maxY + r + spacing;
const packMinY = minY - r - spacing;
const rows = {};
for (const [x, y] of positions) {
const key = Math.round(y * 1000);
if (!rows[key]) rows[key] = [];
rows[key].push([x, y]);
}
const rowKeys = Object.keys(rows).map(Number).sort((a, b) => a - b);
const topYKey = rowKeys[rowKeys.length - 1];
const bottomYKey = rowKeys[0];
rows[topYKey].sort((a, b) => a[0] - b[0]);
rows[bottomYKey].sort((a, b) => a[0] - b[0]);
const topEdge = packMaxY;
const bottomEdge = packMinY;
for (let i = 0; i < rows[topYKey].length - 1; i++) {
const bmsX = (rows[topYKey][i][0] + rows[topYKey][i + 1][0]) / 2;
const bmsY = topEdge;
for (const [cellX, cellY] of positions) {
const distance = Math.hypot(bmsX - cellX, bmsY - cellY);
const minDistance = bmsHoleRadius + cellRadius;
if (distance < minDistance) {
const maxDiameter = (distance - cellRadius) * 2;
setStats(`BMS hole overlaps cells! Max diameter: ${maxDiameter.toFixed(1)}mm`, '#ef4444');
canvasState.currentPositions = [];
clearCanvas();
return;
}
}
}
for (let i = 0; i < rows[bottomYKey].length - 1; i++) {
const bmsX = (rows[bottomYKey][i][0] + rows[bottomYKey][i + 1][0]) / 2;
const bmsY = bottomEdge;
for (const [cellX, cellY] of positions) {
const distance = Math.hypot(bmsX - cellX, bmsY - cellY);
const minDistance = bmsHoleRadius + cellRadius;
if (distance < minDistance) {
setStats(`BMS hole overlaps cells! Reduce hole diameter.`, '#ef4444');
canvasState.currentPositions = [];
clearCanvas();
return;
}
}
}
}
if (coverThickness > cellSize / 2) {
setStats(`Cover thickness (${coverThickness}mm) very large for cell size (${cellSize}mm)`, '#f59e0b');
}
if (spacing < 0.5 && spacing > 0) {
setStats(`Spacing < 0.5mm may be difficult to 3D print`, '#f59e0b');
}
if (positions.length < 2) {
setStats(`Only ${positions.length} cell fits. Increase pack size for practical holder.`, '#f59e0b');
} else {
stats.style.color = '#10b981';
}
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,
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('tabWidth')?.value) || 4.0) - 1,
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);
const blockedByBusbarId = {};
busbarStore.list.forEach((bb, i) => {
const g = lastComputedGeometries[i];
if (g && g.blocked) blockedByBusbarId[bb.id] = g.blocked.reason;
});
renderBusbarList(blockedByBusbarId);
const minX = Math.min(...positions.map(p => p[0]));
const minY = Math.min(...positions.map(p => p[1]));
const maxX = Math.max(...positions.map(p => p[0]));
const maxY = Math.max(...positions.map(p => p[1]));
const actualWidth = maxX - minX + cellSize + spacing * 2;
const actualHeight = maxY - minY + cellSize + spacing * 2;
if (positions.length >= 2) {
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);
setStats('Error: ' + error.message, '#ef4444');
}
}
export async function generateLayout() {
const layoutType = document.getElementById('layoutType').value;
if (!ocRef.initialized) {
showStatus('3D engine not ready. Please wait...', 'error');
await initOC();
if (!ocRef.initialized) return;
}
showLoading(true, 'Generating 3D Model', 'Please be patient...');
await new Promise(resolve => setTimeout(resolve, 50));
try {
const xDim = parseFloat(document.getElementById('xDim').value);
const yDim = parseFloat(document.getElementById('yDim').value);
const spacing = parseFloat(document.getElementById('spacing').value);
const cellSize = parseFloat(document.getElementById('cellSize').value);
const ledgeWidth = parseFloat(document.getElementById('ledgeWidth').value) || 0;
const bmsHoleDiameter = parseFloat(document.getElementById('bmsHoleDiameter').value) || 4.0;
const coverThickness = parseFloat(document.getElementById('coverThickness').value);
const cellRadius = cellSize / 2;
const bmsHoleRadius = bmsHoleDiameter / 2;
if (ledgeWidth > 0 && ledgeWidth >= cellSize) {
showStatus(`Ledge width (${ledgeWidth}mm) must be less than cell diameter (${cellSize}mm)!`, 'error');
showLoading(false);
return;
}
const minPackSize = cellSize + spacing * 2;
if (xDim < minPackSize || yDim < minPackSize) {
showStatus(`Pack too small! Minimum size: ${minPackSize.toFixed(1)}×${minPackSize.toFixed(1)} mm`, 'error');
showLoading(false);
return;
}
if (cellSize > xDim || cellSize > yDim) {
showStatus('Cell diameter is larger than pack dimensions!', 'error');
showLoading(false);
return;
}
if (spacing < 0) {
showStatus('Cell spacing cannot be negative!', 'error');
showLoading(false);
return;
}
const height = parseFloat(document.getElementById('height').value);
const terminalDiameter = 8.0;
const terminalDepth = 1.0;
const roundedCorners = document.getElementById('roundedCorners').checked;
const bmsHolesType = document.getElementById('bmsHolesType').value;
const bmsHoles = bmsHolesType !== 'off';
const useTabs = bmsHolesType === 'tabs';
const useFullCircles = bmsHolesType === 'fullcircles';
const filletBms = false;
const circleHoleOffset = false;
let positions;
let layoutName;
switch (layoutType) {
case 'grid':
positions = generateGridLayout(xDim, yDim, spacing, cellSize);
layoutName = 'Grid Layout';
break;
case 'honeycomb':
positions = generateHoneycombLayout(xDim, yDim, spacing, cellSize);
layoutName = 'Honeycomb Layout';
break;
case 'vertical':
positions = generateVerticalHoneycombLayout(xDim, yDim, spacing, cellSize);
layoutName = 'Vertical Honeycomb';
break;
default:
showStatus('Invalid layout type', 'error');
return;
}
if (bmsHoles && useFullCircles) {
const solveEquilateralY = (wallY, cellY, x1, x2) => {
const xMid = (x1 + x2) / 2;
const flip = cellY < wallY ? -1 : 1;
let lo = flip > 0 ? -Math.PI / 2 : 0, hi = flip > 0 ? 0 : Math.PI / 2;
for (let i = 0; i < 80; i++) {
const alpha = (lo + hi) / 2;
const d = xMid - (x1 + cellRadius * Math.cos(alpha));
const h = (cellY + cellRadius * Math.sin(alpha) - wallY) * flip;
const diff = h - d * Math.sqrt(3);
if (Math.abs(diff) < 1e-8) break;
if (diff < 0) { flip > 0 ? (lo = alpha) : (hi = alpha); } else { flip > 0 ? (hi = alpha) : (lo = alpha); }
}
const alpha = (lo + hi) / 2;
const By = cellY + cellRadius * Math.sin(alpha);
return (wallY + 2 * By) / 3;
};
const minY = Math.min(...positions.map(p => p[1]));
const maxY = Math.max(...positions.map(p => p[1]));
const packMinY = minY - cellRadius - spacing;
const packMaxY = maxY + cellRadius + spacing;
const rows = {};
for (const [x, y] of positions) {
const key = Math.round(y * 1000);
if (!rows[key]) rows[key] = [];
rows[key].push([x, y]);
}
const rowKeys = Object.keys(rows).map(Number).sort((a, b) => a - b);
const visualTopKey = rowKeys[0];
const visualBotKey = rowKeys[rowKeys.length - 1];
rows[visualTopKey].sort((a, b) => a[0] - b[0]);
rows[visualBotKey].sort((a, b) => a[0] - b[0]);
const holePositions = [];
for (let i = 0; i < rows[visualTopKey].length - 1; i++) {
const x1 = rows[visualTopKey][i][0], x2 = rows[visualTopKey][i + 1][0];
const adjY = rows[visualTopKey][i][1];
holePositions.push({ hx: (x1 + x2) / 2, hy: solveEquilateralY(packMinY, adjY, x1, x2) });
}
for (let i = 0; i < rows[visualBotKey].length - 1; i++) {
const x1 = rows[visualBotKey][i][0], x2 = rows[visualBotKey][i + 1][0];
const adjY = rows[visualBotKey][i][1];
holePositions.push({ hx: (x1 + x2) / 2, hy: solveEquilateralY(packMaxY, adjY, x1, x2) });
}
for (const { hx, hy } of holePositions) {
let minDist = Infinity;
for (const [cx, cy] of positions) {
const dist = Math.hypot(hx - cx, hy - cy);
if (dist < minDist) minDist = dist;
}
if (minDist < bmsHoleRadius + cellRadius) {
const maxAllowed = (minDist - cellRadius) * 2;
showStatus(`BMS hole too large, overlaps cell! Max diameter: ${maxAllowed.toFixed(2)}mm`, 'error');
showLoading(false);
return;
}
}
} else if (bmsHoles) {
const minY = Math.min(...positions.map(p => p[1]));
const maxY = Math.max(...positions.map(p => p[1]));
const r = cellSize / 2;
const packMinY = minY - r - spacing;
const packMaxY = maxY + r + spacing;
const rows = {};
for (const [x, y] of positions) {
const key = Math.round(y * 1000);
if (!rows[key]) rows[key] = [];
rows[key].push([x, y]);
}
const rowKeys = Object.keys(rows).map(Number).sort((a, b) => a - b);
const topYKey = rowKeys[rowKeys.length - 1];
const bottomYKey = rowKeys[0];
rows[topYKey].sort((a, b) => a[0] - b[0]);
rows[bottomYKey].sort((a, b) => a[0] - b[0]);
const topY = rows[topYKey][0][1];
const bottomY = rows[bottomYKey][0][1];
let topEdge, bottomEdge;
if (circleHoleOffset) {
const offsetDistance = bmsHoleRadius + 1.0;
topEdge = topY + cellRadius + offsetDistance;
bottomEdge = bottomY - cellRadius - offsetDistance;
} else {
topEdge = packMaxY;
bottomEdge = packMinY;
}
for (let i = 0; i < rows[topYKey].length - 1; i++) {
const bmsX = (rows[topYKey][i][0] + rows[topYKey][i + 1][0]) / 2;
const bmsY = topEdge;
const adjacentCell1 = [rows[topYKey][i][0], rows[topYKey][i][1]];
const adjacentCell2 = [rows[topYKey][i + 1][0], rows[topYKey][i + 1][1]];
for (const [cellX, cellY] of positions) {
if ((cellX === adjacentCell1[0] && cellY === adjacentCell1[1]) ||
(cellX === adjacentCell2[0] && cellY === adjacentCell2[1])) continue;
const distance = Math.hypot(bmsX - cellX, bmsY - cellY);
if (distance < bmsHoleRadius + cellRadius + ledgeWidth) {
showStatus(`BMS hole (${bmsHoleDiameter}mm) collides with cell ledges! Reduce BMS hole size or increase spacing.`, 'error');
showLoading(false);
return;
}
}
}
for (let i = 0; i < rows[bottomYKey].length - 1; i++) {
const bmsX = (rows[bottomYKey][i][0] + rows[bottomYKey][i + 1][0]) / 2;
const bmsY = bottomEdge;
const adjacentCell1 = [rows[bottomYKey][i][0], rows[bottomYKey][i][1]];
const adjacentCell2 = [rows[bottomYKey][i + 1][0], rows[bottomYKey][i + 1][1]];
for (const [cellX, cellY] of positions) {
if ((cellX === adjacentCell1[0] && cellY === adjacentCell1[1]) ||
(cellX === adjacentCell2[0] && cellY === adjacentCell2[1])) continue;
const distance = Math.hypot(bmsX - cellX, bmsY - cellY);
if (distance < bmsHoleRadius + cellRadius + ledgeWidth) {
showStatus(`BMS hole (${bmsHoleDiameter}mm) collides with cell ledges! Reduce BMS hole size or increase spacing.`, 'error');
showLoading(false);
return;
}
}
}
}
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';
const config = {
cellSize, spacing, height, terminalDiameter, terminalDepth,
coverThickness, roundedCorners, bmsHoles, ledgeWidth,
filletBms, circleHoleOffset, useTabs, useFullCircles, bmsHoleDiameter,
tabWidth: edgeCutWidth, tabLength, tabDepth: 1.0, tabOverlapSide, layoutType,
};
const holderShape = create3DModel(positions, config);
if (!holderShape) {
showStatus('Failed to create 3D model', 'error');
return;
}
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,
packBounds,
bb.overlapEnabled !== false,
layoutType,
bb.overlapSize,
busbarCellCutoutEnabled,
)
);
attachEdgeTabsToNearestBusbars(busbarStore.list, busbarGeometries, positions, {
enabled: bmsHolesType === 'tabs',
cellRadius,
spacing,
tabWidth: edgeCutWidth - 1,
tabOverlapSide,
overlapLength: height,
layoutType,
});
for (let i = 0; i < busbarStore.list.length; i++) {
const bb = busbarStore.list[i];
const geom = busbarGeometries[i];
if (geom.blocked) {
showStatus(`${bb.name}: ${geom.blocked.reason}. Cannot export.`, 'error');
showLoading(false);
return;
}
}
const holderCenterX = (Math.min(...positions.map(p => p[0])) + Math.max(...positions.map(p => p[0]))) / 2;
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]);
// 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, 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)}|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++) {
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)}|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
// 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 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(centeredGeom(busbarGeometries[i], holderCenterX, holderCenterY), centeredPositions, busbarPadRadius, height, bb.thickness);
if (!entry.shape) continue;
}
uniqueBusbars.push(entry);
if (sig) sigSeen.set(sig, entry);
}
// 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(centeredGeom(geom, holderCenterX, holderCenterY), centeredPositions, busbarPadRadius);
downloadDXF(content, `${base}.dxf`);
} else {
downloadSTEP(shape, `${base}.step`);
}
}
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' : '';
showStatus(
`${layoutName} generated. ${positions.length} cells (${holeType}${filletMsg})${busbarMsg}.`,
'success'
);
} catch (error) {
console.error('Generation error:', error);
showStatus('Error: ' + error.message, 'error');
} finally {
showLoading(false);
}
}
// ── 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;
const cx = (Math.min(...positions.map(p => p[0])) + Math.max(...positions.map(p => p[0]))) / 2;
const cy = (Math.min(...positions.map(p => p[1])) + Math.max(...positions.map(p => p[1]))) / 2;
const centeredPositions = positions.map(([x, y]) => [x - cx, y - cy]);
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, centerX: cx, centerY: cy, padRadius, height, busbarFormat, safeName };
}
export async function downloadSingleBusbar(busbarId) {
const ctx = getBusbarExportContext();
if (!ctx) {
showStatus('Configure the layout first to enable busbar downloads.', 'error');
return;
}
const bbIdx = busbarStore.list.findIndex(b => b.id === busbarId);
if (bbIdx < 0) return;
const bb = busbarStore.list[bbIdx];
if (bb.cellIndices.length === 0) {
showStatus(`${bb.name} has no cells assigned.`, 'error');
return;
}
const geom = lastComputedGeometries[bbIdx];
if (!geom || geom.blocked) {
showStatus(`${bb.name}: ${geom?.blocked?.reason ?? 'geometry unavailable'}`, 'error');
return;
}
if (ctx.busbarFormat === 'step' && !ocRef.initialized) {
showStatus('3D engine not ready. Please wait.', 'error');
return;
}
const base = `busbar_${ctx.safeName(bb.name)}`;
showLoading(true, `Exporting ${bb.name}`, '');
await new Promise(r => setTimeout(r, 20));
try {
if (ctx.busbarFormat === 'dxf') {
const content = buildBusbarDXF(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius);
downloadDXF(content, `${base}.dxf`);
} else {
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`);
}
} catch (e) {
showStatus(`Export error: ${e.message}`, 'error');
} finally {
showLoading(false);
}
}
export async function downloadAllBusbarsZip() {
const ctx = getBusbarExportContext();
if (!ctx) {
showStatus('Configure the layout first to enable busbar downloads.', 'error');
return;
}
const eligible = busbarStore.list
.map((bb, i) => ({ bb, geom: lastComputedGeometries[i], i }))
.filter(({ bb, geom }) => bb.cellIndices.length > 0 && geom && !geom.blocked);
if (eligible.length === 0) {
showStatus('No busbars with cells to export.', 'error');
return;
}
if (ctx.busbarFormat === 'step' && !ocRef.initialized) {
showStatus('3D engine not ready. Please wait.', 'error');
return;
}
showLoading(true, 'Building busbar ZIP', 'Please wait...');
await new Promise(r => setTimeout(r, 50));
try {
const { default: JSZip } = await import('jszip');
const zip = new JSZip();
for (const { bb, geom } of eligible) {
const base = `busbar_${ctx.safeName(bb.name)}`;
if (ctx.busbarFormat === 'dxf') {
const content = buildBusbarDXF(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius);
zip.file(`${base}.dxf`, content);
} else {
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);
}
}
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'busbars.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showStatus(`Downloaded busbars.zip (${eligible.length} file${eligible.length === 1 ? '' : 's'}).`, 'success');
} catch (e) {
console.error('ZIP export error:', e);
showStatus('ZIP export error: ' + e.message, 'error');
} finally {
showLoading(false);
}
}