1037 lines
45 KiB
JavaScript
1037 lines
45 KiB
JavaScript
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);
|
||
}
|
||
}
|