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

File diff suppressed because one or more lines are too long

1
dist/assets/index-DFysVyGJ.css vendored Normal file

File diff suppressed because one or more lines are too long

26
dist/assets/index-DwWFs8GA.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

12
dist/assets/jszip.min-BiHF8TMC.js vendored Normal file

File diff suppressed because one or more lines are too long

53
dist/index.html vendored
View File

@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="theme-color" content="#0a0f1e">
<title>Cell Holder Generator</title>
<script type="module" crossorigin src="./assets/index-jBPW89B_.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CXz5-edo.css">
<title>Cell Holder Generator - waak.me</title>
<script type="module" crossorigin src="./assets/index-DwWFs8GA.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DFysVyGJ.css">
</head>
<body>
<div class="loading-overlay active" id="loadingOverlay" style="display: flex !important;">
@@ -18,7 +18,10 @@
</div>
<div class="container">
<h1>Cell Holder Generator</h1>
<div class="header-row">
<h1>Cell Holder Generator</h1>
<p class="repo-credit">Forked from <a href="https://github.com/waak86/battery-builder" target="_blank" rel="noopener noreferrer">waak's Battery Builder</a> <br>Huge thanks to <a href="https://t.me/waak86" target="_blank" rel="noopener noreferrer">waak</a> for the original project. </p>
</div>
<p class="subtitle">Generate custom 3D printable cell holders with STEP export</p>
<div class="main-layout">
@@ -160,6 +163,15 @@
<input type="number" id="tabDepth" value="1.0" min="0.1" step="0.1">
</div>
</div>
<div class="form-group" id="tabOverlapSideGroup" style="display:none;">
<label>Tab Overlap Side</label>
<select id="tabOverlapSide">
<option value="off" selected>Off</option>
<option value="top">Top</option>
<option value="bottom">Bottom</option>
</select>
<span class="field-hint">Adds one continuous outside lip across the selected edge tabs.</span>
</div>
</section>
<section class="tab-panel" role="tabpanel" data-panel="busbars">
@@ -171,8 +183,28 @@
</select>
<span class="field-hint">STEP exports a 3D solid for CAD or 3D printing. DXF exports a flat 2D outline for laser or plasma cutters.</span>
</div>
<div class="checkbox-group">
<input type="checkbox" id="busbarCellCutoutEnabled">
<label for="busbarCellCutoutEnabled">Cell center cutout (5x2 mm) for all busbars</label>
</div>
<div class="busbar-controls-row">
<div class="busbar-face-filter" role="group" aria-label="Face filter">
<button type="button" class="face-filter-btn active" data-filter="both">Top &amp; Bottom</button>
<button type="button" class="face-filter-btn" data-filter="top">Top only</button>
</div>
<div class="busbar-controls-right">
<button type="button" class="btn-ghost btn-dl-all" id="downloadAllBusbarsBtn" title="Download all busbars as a ZIP file">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v13M7 11l5 5 5-5"/><path d="M4 20h16"/></svg>
ZIP all
</button>
<button type="button" class="btn-ghost btn-clear-markings" id="clearMarkingsBtn" title="Remove all cell assignments without deleting busbars">Clear</button>
</div>
</div>
<div id="busbarList"></div>
<button class="btn-secondary" id="addBusbarBtn">+ Add Busbar</button>
<div class="busbar-add-row">
<button class="btn-secondary" id="addTopBusbarBtn">+ Top Busbar</button>
<button class="btn-secondary" id="addBottomBusbarBtn">+ Bottom Busbar</button>
</div>
</section>
</div>
@@ -182,7 +214,16 @@
<div class="preview-container">
<h2>Preview</h2>
<canvas id="preview"></canvas>
<div class="previews-row">
<div class="preview-face-wrap">
<div class="face-label" id="topFaceLabel">Top</div>
<canvas id="preview"></canvas>
</div>
<div class="preview-face-wrap" id="bottomFaceWrap">
<div class="face-label" id="bottomFaceLabel">Bottom</div>
<canvas id="preview-bottom"></canvas>
</div>
</div>
<div id="previewStats">Configure settings and click Generate to see preview</div>
</div>
</div>

View File

@@ -154,14 +154,19 @@
</div>
<div class="row" id="tabDimensionsGroup" style="display:none;">
<div class="form-group">
<label>Tab Width (mm)</label>
<input type="number" id="tabWidth" value="4.0" min="0.5" step="0.1">
</div>
<div class="form-group">
<label>Tab Depth (mm)</label>
<input type="number" id="tabDepth" value="1.0" min="0.1" step="0.1">
<label>Tab Length (mm)</label>
<input type="number" id="tabLength" value="10.0" min="1" step="0.5">
</div>
</div>
<div class="form-group" id="tabOverlapSideGroup" style="display:none;">
<label>Tab Overlap Side</label>
<select id="tabOverlapSide">
<option value="off" selected>Off</option>
<option value="top">Top</option>
<option value="bottom">Bottom</option>
</select>
<span class="field-hint">Adds one continuous outside lip across the selected edge tabs.</span>
</div>
</section>
<section class="tab-panel" role="tabpanel" data-panel="busbars">
@@ -173,6 +178,10 @@
</select>
<span class="field-hint">STEP exports a 3D solid for CAD or 3D printing. DXF exports a flat 2D outline for laser or plasma cutters.</span>
</div>
<div class="checkbox-group">
<input type="checkbox" id="busbarCellCutoutEnabled">
<label for="busbarCellCutoutEnabled">Cell center cutout (5x2 mm) for all busbars</label>
</div>
<div class="busbar-controls-row">
<div class="busbar-face-filter" role="group" aria-label="Face filter">
<button type="button" class="face-filter-btn active" data-filter="both">Top &amp; Bottom</button>

15
node_modules/.package-lock.json generated vendored
View File

@@ -29,9 +29,20 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [

View File

@@ -1,13 +1,13 @@
{
"hash": "768e60fe",
"configHash": "cddbe005",
"lockfileHash": "6cdf80c6",
"browserHash": "40f95f93",
"hash": "c96d213e",
"configHash": "c79dcb49",
"lockfileHash": "6e69140d",
"browserHash": "2de19426",
"optimized": {
"jszip": {
"src": "../../jszip/dist/jszip.min.js",
"file": "jszip.js",
"fileHash": "26c0effe",
"fileHash": "d21eabfe",
"needsInterop": true
}
},

3
node_modules/@rollup/rollup-linux-x64-musl/README.md generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# `@rollup/rollup-linux-x64-musl`
This is the **x86_64-unknown-linux-musl** binary for `rollup`

View File

@@ -0,0 +1,25 @@
{
"name": "@rollup/rollup-linux-x64-musl",
"version": "4.60.2",
"os": [
"linux"
],
"cpu": [
"x64"
],
"files": [
"rollup.linux-x64-musl.node"
],
"description": "Native bindings for Rollup",
"author": "Lukas Taegert-Atkinson",
"homepage": "https://rollupjs.org/",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/rollup/rollup.git"
},
"libc": [
"musl"
],
"main": "./rollup.linux-x64-musl.node"
}

Binary file not shown.

39
package-lock.json generated
View File

@@ -497,9 +497,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -514,9 +511,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -531,9 +525,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -548,9 +539,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -565,9 +553,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -582,9 +567,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -599,9 +581,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -616,9 +595,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -633,9 +609,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -650,9 +623,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -667,9 +637,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -684,9 +651,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -701,9 +665,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

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

View File

@@ -155,12 +155,238 @@ function smoothPolylinePoints(points, padRadius) {
return out;
}
export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRadius, spacing, keepoutRadius) {
function inferHorizontalPitch(positions) {
const epsilon = 1e-3;
const rows = new Map();
for (const pos of positions) {
const key = pos[1].toFixed(4);
if (!rows.has(key)) rows.set(key, []);
rows.get(key).push(pos[0]);
}
let pitch = Infinity;
for (const rowXs of rows.values()) {
rowXs.sort((a, b) => a - b);
for (let index = 1; index < rowXs.length; index++) {
const delta = rowXs[index] - rowXs[index - 1];
if (delta > epsilon) pitch = Math.min(pitch, delta);
}
}
return Number.isFinite(pitch) ? pitch : 0;
}
function computeBoundaryRoundoverFeatures(cellIndices, positions, padRadius) {
if (cellIndices.length < 2) {
return { extraPads: [], extraSegments: [] };
}
const selected = cellIndices
.map((index) => ({ index, pos: positions[index] }))
.filter((entry) => Array.isArray(entry.pos) && entry.pos.length >= 2);
if (selected.length !== cellIndices.length) {
return { extraPads: [], extraSegments: [] };
}
const epsilon = 1e-3;
const horizontalPitch = inferHorizontalPitch(positions);
if (horizontalPitch <= epsilon) {
return { extraPads: [], extraSegments: [] };
}
const xTolerance = Math.max(0.5, horizontalPitch * 0.25);
const yTolerance = 1e-3;
const hasHorizontalNeighbor = (entry, direction) => positions.some((pos) => {
if (Math.abs(pos[1] - entry.pos[1]) > yTolerance) return false;
const deltaX = pos[0] - entry.pos[0];
if (direction === 'left' && deltaX >= -epsilon) return false;
if (direction === 'right' && deltaX <= epsilon) return false;
return Math.abs(Math.abs(deltaX) - horizontalPitch) <= xTolerance;
});
const rows = new Map();
for (const entry of selected) {
const key = entry.pos[1].toFixed(4);
if (!rows.has(key)) rows.set(key, []);
rows.get(key).push(entry);
}
const leftBoundary = [];
const rightBoundary = [];
for (const rowEntries of rows.values()) {
rowEntries.sort((a, b) => a.pos[0] - b.pos[0]);
leftBoundary.push(rowEntries[0]);
rightBoundary.push(rowEntries[rowEntries.length - 1]);
}
const leftExposed = leftBoundary.filter((entry) => !hasHorizontalNeighbor(entry, 'left'));
const rightExposed = rightBoundary.filter((entry) => !hasHorizontalNeighbor(entry, 'right'));
const extraPads = [];
const extraSegments = [];
const fillRadius = Math.max(0.2, padRadius * 0.55);
const outwardShift = Math.max(0.2, padRadius * 0.22);
const addSideRoundovers = (entries, side) => {
const direction = side === 'left' ? -1 : 1;
const ordered = entries.slice().sort((a, b) => a.pos[1] - b.pos[1]);
for (let index = 0; index < ordered.length - 1; index++) {
const top = ordered[index];
const bottom = ordered[index + 1];
const fillKey = `boundary_round_${side}_${index}`;
const fillPos = [
(top.pos[0] + bottom.pos[0]) / 2 + direction * outwardShift,
(top.pos[1] + bottom.pos[1]) / 2,
];
extraPads.push({ key: fillKey, pos: fillPos, radius: fillRadius });
extraSegments.push({ from: fillPos, to: top.pos, fromKey: fillKey, toKey: `c${top.index}`, radius: fillRadius });
extraSegments.push({ from: fillPos, to: bottom.pos, fromKey: fillKey, toKey: `c${bottom.index}`, radius: fillRadius });
}
};
addSideRoundovers(leftExposed, 'left');
addSideRoundovers(rightExposed, 'right');
return { extraPads, extraSegments };
}
function computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapLength) {
if (cellIndices.length < 2) {
return { extraPads: [], extraSegments: [] };
}
const selected = cellIndices
.map((index) => ({ index, pos: positions[index] }))
.filter((entry) => Array.isArray(entry.pos) && entry.pos.length >= 2);
if (selected.length !== cellIndices.length) {
return { extraPads: [], extraSegments: [] };
}
const epsilon = 1e-3;
const horizontalPitch = inferHorizontalPitch(positions);
if (horizontalPitch <= epsilon) {
return { extraPads: [], extraSegments: [] };
}
const xTolerance = Math.max(0.5, horizontalPitch * 0.25);
const yTolerance = 1e-3;
const hasHorizontalNeighbor = (entry, direction) => positions.some((pos) => {
if (Math.abs(pos[1] - entry.pos[1]) > yTolerance) return false;
const deltaX = pos[0] - entry.pos[0];
if (direction === 'left' && deltaX >= -epsilon) return false;
if (direction === 'right' && deltaX <= epsilon) return false;
return Math.abs(Math.abs(deltaX) - horizontalPitch) <= xTolerance;
});
const rows = new Map();
for (const entry of selected) {
const key = entry.pos[1].toFixed(4);
if (!rows.has(key)) rows.set(key, []);
rows.get(key).push(entry);
}
const leftBoundary = [];
const rightBoundary = [];
for (const rowEntries of rows.values()) {
rowEntries.sort((a, b) => a.pos[0] - b.pos[0]);
leftBoundary.push(rowEntries[0]);
rightBoundary.push(rowEntries[rowEntries.length - 1]);
}
const leftExposed = leftBoundary.filter((entry) => !hasHorizontalNeighbor(entry, 'left'));
const rightExposed = rightBoundary.filter((entry) => !hasHorizontalNeighbor(entry, 'right'));
let chosenSide = null;
let boundaryEntries = [];
if (leftExposed.length > rightExposed.length && leftExposed.length > 0) {
chosenSide = 'left';
boundaryEntries = leftExposed;
} else if (rightExposed.length > leftExposed.length && rightExposed.length > 0) {
chosenSide = 'right';
boundaryEntries = rightExposed;
}
if (!chosenSide || boundaryEntries.length === 0) {
return { extraPads: [], extraSegments: [] };
}
const extension = Number.isFinite(Number(overlapLength)) && Number(overlapLength) > 0
? Number(overlapLength)
: 10;
const direction = chosenSide === 'left' ? -1 : 1;
const straightHoneycombX = layoutType === 'honeycomb'
? (direction < 0
? Math.min(...boundaryEntries.map((entry) => entry.pos[0])) - extension
: Math.max(...boundaryEntries.map((entry) => entry.pos[0])) + extension)
: null;
const extraPads = [];
const extraSegments = [];
const overlapPads = boundaryEntries
.slice()
.sort((a, b) => a.pos[1] - b.pos[1])
.map((entry, index) => {
const key = `edge_overlap_${index}`;
const overlapPos = [
straightHoneycombX ?? (entry.pos[0] + direction * extension),
entry.pos[1],
];
extraPads.push({ key, pos: overlapPos });
extraSegments.push({ from: overlapPos, to: entry.pos, fromKey: key, toKey: `c${entry.index}` });
return { key, pos: overlapPos };
});
for (let index = 0; index < overlapPads.length - 1; index++) {
const topOverlapPad = overlapPads[index];
const bottomOverlapPad = overlapPads[index + 1];
const topCell = boundaryEntries[index];
const bottomCell = boundaryEntries[index + 1];
extraSegments.push({
from: topOverlapPad.pos,
to: bottomOverlapPad.pos,
fromKey: topOverlapPad.key,
toKey: bottomOverlapPad.key,
});
// Large overlap offsets open a trapezoid between two neighboring connector
// legs and the outer spine. Add one interior hub and connect it to the
// bay corners so the shared pad/capsule geometry stays fully filled.
const fillKey = `edge_overlap_fill_${index}`;
const fillPos = [
(topOverlapPad.pos[0] + bottomOverlapPad.pos[0] + topCell.pos[0] + bottomCell.pos[0]) / 4,
(topOverlapPad.pos[1] + bottomOverlapPad.pos[1] + topCell.pos[1] + bottomCell.pos[1]) / 4,
];
extraPads.push({ key: fillKey, pos: fillPos });
extraSegments.push({ from: fillPos, to: topOverlapPad.pos, fromKey: fillKey, toKey: topOverlapPad.key });
extraSegments.push({ from: fillPos, to: bottomOverlapPad.pos, fromKey: fillKey, toKey: bottomOverlapPad.key });
extraSegments.push({ from: fillPos, to: topCell.pos, fromKey: fillKey, toKey: `c${topCell.index}` });
extraSegments.push({ from: fillPos, to: bottomCell.pos, fromKey: fillKey, toKey: `c${bottomCell.index}` });
}
return { extraPads, extraSegments };
}
function computeCellCutouts(cellIndices, positions, cellCutoutEnabled) {
if (cellCutoutEnabled !== true) return [];
return cellIndices
.map((index) => positions[index])
.filter((pos) => Array.isArray(pos) && pos.length >= 2)
.map((pos) => ({ center: pos.slice(), width: 5, height: 2 }));
}
export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRadius, spacing, keepoutRadius, packBounds = null, overlapEnabled = true, layoutType = 'grid', overlapSize = 10, cellCutoutEnabled = false) {
if (cellIndices.length === 0) {
return { padIndices: [], edges: [], blocked: null };
return { padIndices: [], edges: [], blocked: null, extraPads: [], extraSegments: [], cutouts: [] };
}
if (cellIndices.length === 1) {
return { padIndices: cellIndices.slice(), edges: [], blocked: null };
return {
padIndices: cellIndices.slice(),
edges: [],
blocked: null,
extraPads: [],
extraSegments: [],
cutouts: computeCellCutouts(cellIndices, positions, cellCutoutEnabled),
};
}
const edgePairs = buildEdgePairs(cellIndices, positions, cellRadius, spacing);
@@ -185,6 +411,9 @@ export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRad
padIndices: cellIndices.slice(),
edges,
blocked: { from: i, to: j, reason: 'no clear route between these cells' },
extraPads: [],
extraSegments: [],
cutouts: [],
};
}
}
@@ -200,5 +429,18 @@ export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRad
};
});
return { padIndices: cellIndices.slice(), edges: roundedEdges, blocked: null };
const overlapFeatures = overlapEnabled
? computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapSize)
: { extraPads: [], extraSegments: [] };
const roundoverFeatures = computeBoundaryRoundoverFeatures(cellIndices, positions, padRadius);
const cutouts = computeCellCutouts(cellIndices, positions, cellCutoutEnabled);
return {
padIndices: cellIndices.slice(),
edges: roundedEdges,
blocked: null,
extraPads: [...overlapFeatures.extraPads, ...roundoverFeatures.extraPads],
extraSegments: [...overlapFeatures.extraSegments, ...roundoverFeatures.extraSegments],
cutouts,
};
}

View File

@@ -5,6 +5,9 @@ export function build3DBusbar(geometry, positions, padRadius, zBase, thickness)
if (!oc || geometry.padIndices.length === 0) return null;
const shapes = [];
const extraPads = Array.isArray(geometry.extraPads) ? geometry.extraPads : [];
const extraSegments = Array.isArray(geometry.extraSegments) ? geometry.extraSegments : [];
const cutouts = Array.isArray(geometry.cutouts) ? geometry.cutouts : [];
for (const i of geometry.padIndices) {
if (!positions[i]) continue;
@@ -13,6 +16,13 @@ export function build3DBusbar(geometry, positions, padRadius, zBase, thickness)
shapes.push(new oc.BRepPrimAPI_MakeCylinder(ax, padRadius, thickness).Shape());
}
for (const pad of extraPads) {
const [x, y] = pad.pos;
const radius = pad.radius ?? padRadius;
const ax = new oc.gp_Ax2(new oc.gp_Pnt(x, y, zBase), oc.gp.prototype.DZ());
shapes.push(new oc.BRepPrimAPI_MakeCylinder(ax, radius, thickness).Shape());
}
for (const edge of geometry.edges) {
const pts = [positions[edge.from], ...edge.waypoints, positions[edge.to]];
@@ -48,10 +58,49 @@ export function build3DBusbar(geometry, positions, padRadius, zBase, thickness)
}
}
for (const segment of extraSegments) {
const [x1, y1] = segment.from;
const [x2, y2] = segment.to;
const segmentRadius = segment.radius ?? padRadius;
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1e-6) continue;
const angle = Math.atan2(dy, dx);
const box = new oc.BRepPrimAPI_MakeBox(len, 2 * segmentRadius, thickness).Shape();
const trans1 = new oc.gp_Trsf();
trans1.SetTranslation(new oc.gp_Vec(0, -segmentRadius, 0));
let shape = new oc.BRepBuilderAPI_Transform(box, trans1, false).Shape();
const rot = new oc.gp_Trsf();
rot.SetRotation(new oc.gp_Ax1(new oc.gp_Pnt(0, 0, 0), oc.gp.prototype.DZ()), angle);
shape = new oc.BRepBuilderAPI_Transform(shape, rot, false).Shape();
const trans2 = new oc.gp_Trsf();
trans2.SetTranslation(new oc.gp_Vec(x1, y1, zBase));
shape = new oc.BRepBuilderAPI_Transform(shape, trans2, false).Shape();
shapes.push(shape);
}
if (shapes.length === 0) return null;
let combined = shapes[0];
for (let i = 1; i < shapes.length; i++) {
combined = new oc.BRepAlgoAPI_Fuse(combined, shapes[i]).Shape();
}
for (const cutout of cutouts) {
const [cx, cy] = cutout.center;
const width = cutout.width;
const height = cutout.height;
const cutter = new oc.BRepPrimAPI_MakeBox(width, height, thickness).Shape();
const trans = new oc.gp_Trsf();
trans.SetTranslation(new oc.gp_Vec(cx - width / 2, cy - height / 2, zBase));
const placedCutter = new oc.BRepBuilderAPI_Transform(cutter, trans, false).Shape();
combined = new oc.BRepAlgoAPI_Cut(combined, placedCutter).Shape();
}
return combined;
}

View File

@@ -20,7 +20,10 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
const cellR = cellSize / 2;
const toScreenX = (wx) => (wx - t.minX + cellR + spacing) * t.scale + t.offsetX;
const toScreenY = (wy) => (wy - t.minY + cellR + spacing) * t.scale + t.offsetY;
const padRadiusScreen = padRadius * t.scale;
const padRadiusScreen = (radius) => (radius ?? padRadius) * t.scale;
const extraSegments = (geom) => Array.isArray(geom?.extraSegments) ? geom.extraSegments : [];
const extraPads = (geom) => Array.isArray(geom?.extraPads) ? geom.extraPads : [];
const cutouts = (geom) => Array.isArray(geom?.cutouts) ? geom.cutouts : [];
ctx.save();
ctx.translate(canvasState.panX, canvasState.panY);
@@ -81,7 +84,7 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
// 1. Capsule strokes between direct neighbours (lineCap:round = semicircle
// at each end, so two meeting capsules form a full circle at every junction).
off.strokeStyle = opaqueColor;
off.lineWidth = 2 * padRadiusScreen;
off.lineWidth = 2 * padRadiusScreen();
off.lineCap = 'round';
off.lineJoin = 'round';
for (const [a, b] of adjCaps) {
@@ -91,22 +94,36 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
off.lineTo(toScreenX(pb[0]), toScreenY(pb[1]));
off.stroke();
}
for (const segment of extraSegments(geom)) {
off.lineWidth = 2 * padRadiusScreen(segment.radius);
off.beginPath();
off.moveTo(toScreenX(segment.from[0]), toScreenY(segment.from[1]));
off.lineTo(toScreenX(segment.to[0]), toScreenY(segment.to[1]));
off.stroke();
}
// 2. Circle fills at every cell pad.
off.fillStyle = opaqueColor;
off.beginPath();
for (const ci of cellIndices) {
const p = positions[ci]; if (!p) continue;
const radius = padRadiusScreen();
const sx = toScreenX(p[0]), sy = toScreenY(p[1]);
off.moveTo(sx + padRadiusScreen, sy);
off.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2);
off.moveTo(sx + radius, sy);
off.arc(sx, sy, radius, 0, Math.PI * 2);
}
for (const pad of extraPads(geom)) {
const radius = padRadiusScreen(pad.radius);
const sx = toScreenX(pad.pos[0]);
const sy = toScreenY(pad.pos[1]);
off.moveTo(sx + radius, sy);
off.arc(sx, sy, radius, 0, Math.PI * 2);
}
off.fill();
// 3. Triangle fills for every mutually-adjacent triplet.
// The centre of three packed circles (the "interstice") lies outside all
// three circles AND all three capsules, so without this step those points
// remain unfilled and appear as sharp V-notches.
// 3. Fill the three-circle interstice without extending to the cell centers.
// Use curved joins through the centroid so boundary bays stay rounded
// instead of forming straight-sided inward peaks.
off.fillStyle = opaqueColor;
for (let a = 0; a < nCells; a++) {
for (const b of triAdj[a]) {
@@ -118,10 +135,43 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
const pb = positions[cellIndices[b]];
const pc = positions[cellIndices[c]];
if (!pa || !pb || !pc) continue;
const centroid = [
(pa[0] + pb[0] + pc[0]) / 3,
(pa[1] + pb[1] + pc[1]) / 3,
];
const tangentPoints = [pa, pb, pc].map((point) => {
const dx = point[0] - centroid[0];
const dy = point[1] - centroid[1];
const len = Math.hypot(dx, dy);
if (len < 1e-6 || len <= padRadius) return point;
const inset = padRadius / len;
return [
point[0] - dx * inset,
point[1] - dy * inset,
];
});
off.beginPath();
off.moveTo(toScreenX(pa[0]), toScreenY(pa[1]));
off.lineTo(toScreenX(pb[0]), toScreenY(pb[1]));
off.lineTo(toScreenX(pc[0]), toScreenY(pc[1]));
off.moveTo(toScreenX(tangentPoints[0][0]), toScreenY(tangentPoints[0][1]));
off.quadraticCurveTo(
toScreenX(centroid[0]),
toScreenY(centroid[1]),
toScreenX(tangentPoints[1][0]),
toScreenY(tangentPoints[1][1]),
);
off.quadraticCurveTo(
toScreenX(centroid[0]),
toScreenY(centroid[1]),
toScreenX(tangentPoints[2][0]),
toScreenY(tangentPoints[2][1]),
);
off.quadraticCurveTo(
toScreenX(centroid[0]),
toScreenY(centroid[1]),
toScreenX(tangentPoints[0][0]),
toScreenY(tangentPoints[0][1]),
);
off.closePath();
off.fill();
}
@@ -130,7 +180,7 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
// 4. Obstacle-avoidance detour waypoints (spanning-tree edges with bends).
off.strokeStyle = opaqueColor;
off.lineWidth = 2 * padRadiusScreen;
off.lineWidth = 2 * padRadiusScreen();
off.lineCap = 'round';
for (const edge of geom.edges) {
if (edge.waypoints.length === 0) continue;
@@ -142,6 +192,17 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
off.stroke();
}
off.save();
off.globalCompositeOperation = 'destination-out';
for (const cutout of cutouts(geom)) {
const width = cutout.width * t.scale;
const height = cutout.height * t.scale;
const cx = toScreenX(cutout.center[0]);
const cy = toScreenY(cutout.center[1]);
off.fillRect(cx - width / 2, cy - height / 2, width, height);
}
off.restore();
// Composite offscreen onto main canvas at fillAlpha (reset transform for pixel-exact drawImage)
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -158,7 +219,7 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
const [x, y] = positions[i];
const sx = toScreenX(x), sy = toScreenY(y);
ctx.beginPath();
ctx.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2);
ctx.arc(sx, sy, padRadiusScreen(), 0, Math.PI * 2);
ctx.stroke();
}
}

View File

@@ -31,13 +31,22 @@ function buildBusbarRow(bb, blockedByBusbarId) {
row.innerHTML = `
<div class="busbar-header">
<div class="busbar-swatch" style="background:${bb.color}"></div>
<label class="busbar-color-wrap" title="Busbar color">
<input class="busbar-color" type="color" value="${escapeHtml(bb.color)}" aria-label="Busbar color for ${escapeHtml(bb.name)}">
</label>
<input class="busbar-name" type="text" value="${escapeHtml(bb.name)}">
<button class="busbar-dl" title="Download this busbar">${DL_ICON}</button>
<button class="busbar-del" title="Delete">×</button>
</div>
<div class="busbar-meta">
<span class="busbar-count">${bb.cellIndices.length} cell${bb.cellIndices.length === 1 ? '' : 's'}</span>
<label class="busbar-overlap-label">
<input class="busbar-overlap" type="checkbox" ${bb.overlapEnabled === true ? 'checked' : ''}>
Overlap
</label>
<label class="busbar-overlap-size-label">Size
<input class="busbar-overlap-size" type="number" value="${bb.overlapSize ?? 10}" step="0.5" min="0.5" ${bb.overlapEnabled === true ? '' : 'disabled'}>
</label>
<label class="busbar-thickness-label">Thickness
<input class="busbar-thickness" type="number" value="${bb.thickness}" step="0.1" min="0.1">
</label>
@@ -52,6 +61,16 @@ function buildBusbarRow(bb, blockedByBusbarId) {
row.querySelector('.busbar-name').addEventListener('change', (e) => {
busbarStore.rename(bb.id, e.target.value);
});
row.querySelector('.busbar-color').addEventListener('input', (e) => {
busbarStore.setColor(bb.id, e.target.value);
});
row.querySelector('.busbar-overlap').addEventListener('change', (e) => {
busbarStore.setOverlapEnabled(bb.id, e.target.checked);
});
row.querySelector('.busbar-overlap-size').addEventListener('change', (e) => {
const v = parseFloat(e.target.value);
if (v > 0) busbarStore.setOverlapSize(bb.id, v);
});
row.querySelector('.busbar-thickness').addEventListener('change', (e) => {
const v = parseFloat(e.target.value);
if (v > 0) busbarStore.setThickness(bb.id, v);

View File

@@ -39,6 +39,10 @@ export const busbarStore = {
color: b.color,
cellIndices: Array.isArray(b.cellIndices) ? [...b.cellIndices] : [],
thickness: b.thickness,
overlapEnabled: b.overlapEnabled === true,
overlapSize: Number.isFinite(Number(b.overlapSize)) && Number(b.overlapSize) > 0
? Number(b.overlapSize)
: 10,
face: b.face === 'bottom' ? 'bottom' : 'top',
})),
};
@@ -58,6 +62,10 @@ export const busbarStore = {
thickness: Number.isFinite(Number(b.thickness)) && Number(b.thickness) > 0
? Number(b.thickness)
: 1.0,
overlapEnabled: b.overlapEnabled === true,
overlapSize: Number.isFinite(Number(b.overlapSize)) && Number(b.overlapSize) > 0
? Number(b.overlapSize)
: 10,
face: b.face === 'bottom' ? 'bottom' : 'top',
}));
@@ -92,6 +100,8 @@ export const busbarStore = {
color,
cellIndices: [],
thickness: 1.0,
overlapEnabled: false,
overlapSize: 10,
face: face === 'bottom' ? 'bottom' : 'top',
};
paletteIdx++;
@@ -138,6 +148,24 @@ export const busbarStore = {
}
},
setOverlapEnabled(id, overlapEnabled) {
const b = this.list.find(b => b.id === id);
if (b) {
b.overlapEnabled = overlapEnabled === true;
this._emitMutation('setOverlapEnabled');
this._notify();
}
},
setOverlapSize(id, overlapSize) {
const b = this.list.find(b => b.id === id);
if (b && Number.isFinite(Number(overlapSize)) && Number(overlapSize) > 0) {
b.overlapSize = Number(overlapSize);
this._emitMutation('setOverlapSize');
this._notify();
}
},
setFace(id, face) {
const b = this.list.find(b => b.id === id);
if (b) {

View File

@@ -148,21 +148,30 @@ function angleCovered(theta, dirs) {
export function buildBusbarDXF(geometry, positions, padRadius) {
const layer = 'busbar';
const tokens = [...dxfHeader(), ...dxfTables(), '0', 'SECTION', '2', 'ENTITIES'];
const extraPads = Array.isArray(geometry.extraPads) ? geometry.extraPads : [];
const extraSegments = Array.isArray(geometry.extraSegments) ? geometry.extraSegments : [];
const cutouts = Array.isArray(geometry.cutouts) ? geometry.cutouts : [];
// Enumerate pads (cells + waypoints) and capsule segments with shared keys so we
// can tell which shapes are "self" vs "other" when clipping.
const pads = new Map();
const caps = []; // { a, b, padKeyA, padKeyB }
const caps = []; // { a, b, padKeyA, padKeyB, radius }
const ensurePad = (key, pos) => {
if (!pads.has(key)) pads.set(key, { pos, dirs: [] });
const ensurePad = (key, pos, radius = padRadius) => {
if (!pads.has(key)) pads.set(key, { pos, dirs: [], radius });
else pads.get(key).radius = Math.max(pads.get(key).radius, radius);
return pads.get(key);
};
for (const idx of geometry.padIndices) {
const p = positions[idx];
if (!p) continue;
ensurePad(`c${idx}`, p);
ensurePad(`c${idx}`, p, padRadius);
}
for (const pad of extraPads) {
if (!pad?.key || !Array.isArray(pad.pos)) continue;
ensurePad(pad.key, pad.pos, pad.radius ?? padRadius);
}
geometry.edges.forEach((edge, ei) => {
@@ -174,7 +183,7 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
...edge.waypoints.map((wp, wi) => ({ key: `w${ei}_${wi}`, pos: wp })),
{ key: `c${edge.to}`, pos: to },
];
for (let i = 1; i < stops.length - 1; i++) ensurePad(stops[i].key, stops[i].pos);
for (let i = 1; i < stops.length - 1; i++) ensurePad(stops[i].key, stops[i].pos, padRadius);
for (let i = 0; i < stops.length - 1; i++) {
const a = stops[i], b = stops[i + 1];
const dx = b.pos[0] - a.pos[0];
@@ -182,19 +191,39 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
const len = Math.hypot(dx, dy);
if (len < EPS) continue;
const ang = Math.atan2(dy, dx);
ensurePad(a.key, a.pos).dirs.push(ang);
ensurePad(b.key, b.pos).dirs.push(ang + Math.PI);
caps.push({ a: a.pos.slice(), b: b.pos.slice(), padKeyA: a.key, padKeyB: b.key });
ensurePad(a.key, a.pos, padRadius).dirs.push(ang);
ensurePad(b.key, b.pos, padRadius).dirs.push(ang + Math.PI);
caps.push({ a: a.pos.slice(), b: b.pos.slice(), padKeyA: a.key, padKeyB: b.key, radius: padRadius });
}
});
extraSegments.forEach((segment, index) => {
if (!Array.isArray(segment?.from) || !Array.isArray(segment?.to)) return;
const fromKey = segment.fromKey || `extra_from_${index}`;
const toKey = segment.toKey || `extra_to_${index}`;
const radius = segment.radius ?? padRadius;
ensurePad(fromKey, segment.from, radius);
ensurePad(toKey, segment.to, radius);
const dx = segment.to[0] - segment.from[0];
const dy = segment.to[1] - segment.from[1];
const len = Math.hypot(dx, dy);
if (len < EPS) return;
const ang = Math.atan2(dy, dx);
ensurePad(fromKey, segment.from, radius).dirs.push(ang);
ensurePad(toKey, segment.to, radius).dirs.push(ang + Math.PI);
caps.push({ a: segment.from.slice(), b: segment.to.slice(), padKeyA: fromKey, padKeyB: toKey, radius });
});
// Pad arcs: parts of each circle not covered by any connected capsule half-disc
// AND not interior to any other capsule rectangle or pad circle.
const padList = Array.from(pads.entries()).map(([key, data]) => ({ key, ...data }));
for (const pad of padList) {
const [cx, cy] = pad.pos;
const localRadius = pad.radius ?? padRadius;
if (pad.dirs.length === 0) {
tokens.push(...circleEntity(cx, cy, padRadius, layer));
tokens.push(...circleEntity(cx, cy, localRadius, layer));
continue;
}
const tangents = [];
@@ -217,21 +246,22 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
// Also require the arc's midpoint to be outside all other shapes so it's
// genuinely on the outline — an arc that enters a neighbour's capsule is
// interior and must be dropped.
const mx = cx + padRadius * Math.cos(mid);
const my = cy + padRadius * Math.sin(mid);
const mx = cx + localRadius * Math.cos(mid);
const my = cy + localRadius * Math.sin(mid);
let buried = false;
for (const cap of caps) {
if (cap.padKeyA === pad.key || cap.padKeyB === pad.key) continue;
if (segInsideCapsule([mx, my], [mx, my], cap.a, cap.b, padRadius).length) { buried = true; break; }
if (segInsideCapsule([mx, my], [mx, my], cap.a, cap.b, cap.radius ?? padRadius).length) { buried = true; break; }
}
if (!buried) {
for (const other of padList) {
if (other.key === pad.key) continue;
const dx = mx - other.pos[0], dy = my - other.pos[1];
if (dx * dx + dy * dy < padRadius * padRadius - EPS) { buried = true; break; }
const otherRadius = other.radius ?? padRadius;
if (dx * dx + dy * dy < otherRadius * otherRadius - EPS) { buried = true; break; }
}
}
if (!buried) tokens.push(...arcEntity(cx, cy, padRadius, t1, t2, layer));
if (!buried) tokens.push(...arcEntity(cx, cy, localRadius, t1, t2, layer));
}
}
@@ -244,8 +274,9 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
const dy = cap.b[1] - cap.a[1];
const len = Math.hypot(dx, dy);
if (len < EPS) continue;
const nx = -dy / len * padRadius;
const ny = dx / len * padRadius;
const localRadius = cap.radius ?? padRadius;
const nx = -dy / len * localRadius;
const ny = dx / len * localRadius;
const sides = [
{ p: [cap.a[0] + nx, cap.a[1] + ny], q: [cap.b[0] + nx, cap.b[1] + ny] },
{ p: [cap.a[0] - nx, cap.a[1] - ny], q: [cap.b[0] - nx, cap.b[1] - ny] },
@@ -254,11 +285,11 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
const insides = [];
for (let j = 0; j < caps.length; j++) {
if (j === ci) continue;
insides.push(...segInsideCapsule(side.p, side.q, caps[j].a, caps[j].b, padRadius));
insides.push(...segInsideCapsule(side.p, side.q, caps[j].a, caps[j].b, caps[j].radius ?? padRadius));
}
for (const pad of padList) {
if (pad.key === cap.padKeyA || pad.key === cap.padKeyB) continue;
insides.push(...segInsideCircle(side.p, side.q, pad.pos, padRadius));
insides.push(...segInsideCircle(side.p, side.q, pad.pos, pad.radius ?? padRadius));
}
const outside = subtractIntervals(insides);
for (const [t0, t1] of outside) {
@@ -272,6 +303,20 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
}
}
for (const cutout of cutouts) {
const [cx, cy] = cutout.center;
const halfWidth = cutout.width / 2;
const halfHeight = cutout.height / 2;
const x1 = cx - halfWidth;
const x2 = cx + halfWidth;
const y1 = cy - halfHeight;
const y2 = cy + halfHeight;
tokens.push(...lineEntity(x1, y1, x2, y1, layer));
tokens.push(...lineEntity(x2, y1, x2, y2, layer));
tokens.push(...lineEntity(x2, y2, x1, y2, layer));
tokens.push(...lineEntity(x1, y2, x1, y1, layer));
}
tokens.push('0', 'ENDSEC', '0', 'EOF');
return tokens.join('\n') + '\n';
}

View File

@@ -144,9 +144,11 @@ function applyConfigToUi(config) {
setSelectInput('bmsHolesType', config.bms.type);
setNumberInput('bmsHoleDiameter', config.bms.holeDiameter);
setNumberInput('tabWidth', config.bms.tabWidth);
setNumberInput('tabDepth', config.bms.tabDepth);
setNumberInput('tabLength', config.bms.tabLength ?? 10.0);
setSelectInput('tabOverlapSide', config.bms.tabOverlapSide || 'off');
setSelectInput('busbarFormat', config.busbars.format);
setCheckboxInput('busbarCellCutoutEnabled', config.busbars.cellCutoutEnabled === true);
setPackMode(config.pack.mode, { clearBusbars: false, refresh: false });
toggleBmsDiameter();
@@ -222,8 +224,8 @@ function wireUrlSyncListeners() {
const ids = [
'series', 'parallel', 'xDim', 'yDim', 'height',
'cellSize', 'layoutType', 'spacing', 'coverThickness', 'ledgeWidth',
'roundedCorners', 'bmsHolesType', 'bmsHoleDiameter', 'tabWidth', 'tabDepth',
'busbarFormat',
'roundedCorners', 'bmsHolesType', 'bmsHoleDiameter', 'tabWidth',
'tabOverlapSide', 'busbarFormat', 'busbarCellCutoutEnabled',
];
ids.forEach((id) => {
@@ -338,7 +340,7 @@ function wireInputs() {
element.addEventListener('change', handler);
});
const visualInputs = ['bmsHolesType', 'roundedCorners', 'bmsHoleDiameter', 'ledgeWidth', 'tabWidth', 'tabDepth'];
const visualInputs = ['bmsHolesType', 'roundedCorners', 'bmsHoleDiameter', 'ledgeWidth', 'tabWidth', 'tabLength', 'tabOverlapSide', 'busbarCellCutoutEnabled'];
visualInputs.forEach(id => {
const element = document.getElementById(id);
if (!element) return;

View File

@@ -17,6 +17,8 @@ export function create3DModel(positions, config) {
useTabs,
useFullCircles,
filletBms,
tabOverlapSide,
layoutType = 'honeycomb',
} = config;
const r = cellSize / 2;
@@ -212,16 +214,51 @@ export function create3DModel(positions, config) {
const topRow = rows[topYKey].sort((a, b) => a[0] - b[0]);
const bottomRow = rows[bottomYKey].sort((a, b) => a[0] - b[0]);
const minAllX = Math.min(...positions.map(([x]) => x));
// Vertical column pitch: minimum X delta between any two cells
const _allXSorted = [...new Set(positions.map(([x]) => Math.round(x * 1000)))]
.sort((a, b) => a - b).map(v => v / 1000);
const vertColPitch = _allXSorted.length >= 2 ? _allXSorted[1] - _allXSorted[0] : 0;
const topPitch = topRow.length >= 2 ? topRow[topRow.length - 1][0] - topRow[topRow.length - 2][0] : 0;
const topHoles = [];
for (let i = 0; i < topRow.length - 1; i++) {
const xMid = (topRow[i][0] + topRow[i + 1][0]) / 2;
topHoles.push([xMid, holeYTop]);
topHoles.push([(topRow[i][0] + topRow[i + 1][0]) / 2, holeYTop]);
}
if (layoutType === 'vertical') {
const topIsEven = vertColPitch === 0 || (topRow[0][0] - minAllX) < vertColPitch / 2;
if (!topIsEven) {
topHoles.unshift([topRow[0][0] - vertColPitch / 2, holeYTop]);
topHoles.push([topRow[topRow.length - 1][0] + vertColPitch / 2, holeYTop]);
}
} else if (layoutType !== 'grid') {
const topExtraRight = topRow.length >= 2 && (topRow[0][0] - minAllX) < topPitch / 4;
if (topExtraRight) {
topHoles.push([topRow[topRow.length - 1][0] + topPitch / 2, holeYTop]);
} else {
topHoles.unshift([topRow[0][0] - topPitch / 2, holeYTop]);
}
}
const bottomPitch = bottomRow.length >= 2 ? bottomRow[bottomRow.length - 1][0] - bottomRow[bottomRow.length - 2][0] : 0;
const bottomHoles = [];
for (let i = 0; i < bottomRow.length - 1; i++) {
const xMid = (bottomRow[i][0] + bottomRow[i + 1][0]) / 2;
bottomHoles.push([xMid, holeYBottom]);
bottomHoles.push([(bottomRow[i][0] + bottomRow[i + 1][0]) / 2, holeYBottom]);
}
if (layoutType === 'vertical') {
const bottomIsEven = vertColPitch === 0 || (bottomRow[0][0] - minAllX) < vertColPitch / 2;
if (!bottomIsEven) {
bottomHoles.unshift([bottomRow[0][0] - vertColPitch / 2, holeYBottom]);
bottomHoles.push([bottomRow[bottomRow.length - 1][0] + vertColPitch / 2, holeYBottom]);
}
} else if (layoutType !== 'grid') {
const bottomExtraRight = bottomRow.length >= 2 && (bottomRow[0][0] - minAllX) < bottomPitch / 4;
if (bottomExtraRight) {
bottomHoles.push([bottomRow[bottomRow.length - 1][0] + bottomPitch / 2, holeYBottom]);
} else {
bottomHoles.unshift([bottomRow[0][0] - bottomPitch / 2, holeYBottom]);
}
}
const allBmsHoles = [...topHoles, ...bottomHoles];
@@ -229,25 +266,28 @@ export function create3DModel(positions, config) {
if (useTabs) {
const slotWidth = config.tabWidth || holeDiameter;
const slotInset = config.tabDepth || 1.0;
const slotHeight = Number.isFinite(config.tabLength) && config.tabLength > 0
? Math.min(config.tabLength, height)
: height;
const topEdgeY = length / 2;
const bottomEdgeY = -length / 2;
const allSlots = [];
topHoles.forEach(([xPos]) => {
const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, height);
const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, slotHeight);
const slot = slotBox.Shape();
const trans = new oc.gp_Trsf();
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, topEdgeY - slotInset, 0));
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, topEdgeY - slotInset, height - slotHeight));
const slotTransform = new oc.BRepBuilderAPI_Transform(slot, trans, false);
allSlots.push(slotTransform.Shape());
});
bottomHoles.forEach(([xPos]) => {
const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, height);
const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, slotHeight);
const slot = slotBox.Shape();
const trans = new oc.gp_Trsf();
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, bottomEdgeY, 0));
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, bottomEdgeY, height - slotHeight));
const slotTransform = new oc.BRepBuilderAPI_Transform(slot, trans, false);
allSlots.push(slotTransform.Shape());
});

View File

@@ -75,6 +75,7 @@ export function drawPreview(positions, cellSize) {
const useTabs = bmsHolesType === 'tabs';
const useFullCircles = bmsHolesType === 'fullcircles';
const circleHoleOffset = false;
const layoutType = document.getElementById('layoutType').value;
const roundedCorners = document.getElementById('roundedCorners').checked;
const bmsHoleDiameter = parseFloat(document.getElementById('bmsHoleDiameter').value) || 4.0;
const ledgeWidth = parseFloat(document.getElementById('ledgeWidth').value) || 0;
@@ -238,11 +239,18 @@ export function drawPreview(positions, cellSize) {
const holeTopRowKey = useFullCircles ? visualTopRowKey : topYKey;
const holeBottomRowKey = useFullCircles ? visualBottomRowKey : bottomYKey;
for (let i = 0; i < rows[holeTopRowKey].length - 1; i++) {
const x = (rows[holeTopRowKey][i][0] + rows[holeTopRowKey][i + 1][0]) / 2;
const cellY = rows[holeTopRowKey][i][1];
const x1 = rows[holeTopRowKey][i][0];
const x2 = rows[holeTopRowKey][i + 1][0];
// Vertical column pitch: minimum X delta between any two cells
const _allXSorted = [...new Set(positions.map(([x]) => Math.round(x * 1000)))]
.sort((a, b) => a - b).map(v => v / 1000);
const vertColPitch = _allXSorted.length >= 2 ? _allXSorted[1] - _allXSorted[0] : 0;
const topTabRow = rows[holeTopRowKey];
const topTabPitch = topTabRow.length >= 2 ? topTabRow[topTabRow.length - 1][0] - topTabRow[topTabRow.length - 2][0] : 0;
for (let i = 0; i < topTabRow.length - 1; i++) {
const x = (topTabRow[i][0] + topTabRow[i + 1][0]) / 2;
const cellY = topTabRow[i][1];
const x1 = topTabRow[i][0];
const x2 = topTabRow[i + 1][0];
const wallY = packMinY;
const y = topEdge;
const flip = cellY < wallY ? -1 : 1;
@@ -261,12 +269,42 @@ export function drawPreview(positions, cellSize) {
const debugTri = useFullCircles ? { apex: { x, y: wallY }, left, right } : null;
bmsHolePositions.push({ x, y, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri });
}
if (layoutType === 'vertical') {
if (!useFullCircles) {
const topIsEven = vertColPitch === 0 || (topTabRow[0][0] - minX) < vertColPitch / 2;
if (!topIsEven) {
const topExtY = topEdge;
// Left corner: continue interior hole pattern (tabPitch/2 = one colPitch to the left)
bmsHolePositions.push({ x: topTabRow[0][0] - topTabPitch / 2, y: topExtY, diameter: bmsHoleDiameter, isTab: false, isFull: false, debugTri: null });
// Right corner only if an even-col neighbor exists to the right
const topRightNeighX = topTabRow[topTabRow.length - 1][0] + vertColPitch;
if (_allXSorted.some(x => Math.abs(x - topRightNeighX) < 0.5)) {
bmsHolePositions.push({ x: topTabRow[topTabRow.length - 1][0] + topTabPitch / 2, y: topExtY, diameter: bmsHoleDiameter, isTab: false, isFull: false, debugTri: null });
}
}
} else {
// Full circles: top-right corner when an odd col exists beyond the last even col (even S)
const topRightNeighX = topTabRow[topTabRow.length - 1][0] + vertColPitch;
if (_allXSorted.some(x => Math.abs(x - topRightNeighX) < 0.5)) {
// Continue the interior hole pattern: offset by topTabPitch/2 (= one colPitch)
bmsHolePositions.push({ x: topTabRow[topTabRow.length - 1][0] + topTabPitch / 2, y: topEdge, diameter: bmsHoleDiameter, isTab: false, isFull: true, debugTri: null });
}
}
} else if (layoutType !== 'grid') {
const topTabExtraRight = topTabRow.length < 2 || (topTabRow[0][0] - minX) < topTabPitch / 4;
bmsHolePositions.push(topTabExtraRight
? { x: topTabRow[topTabRow.length - 1][0] + topTabPitch / 2, y: topEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null }
: { x: topTabRow[0][0] - topTabPitch / 2, y: topEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
}
const botTabRow = rows[holeBottomRowKey];
const botTabPitch = botTabRow.length >= 2 ? botTabRow[botTabRow.length - 1][0] - botTabRow[botTabRow.length - 2][0] : 0;
for (let i = 0; i < botTabRow.length - 1; i++) {
const x = (botTabRow[i][0] + botTabRow[i + 1][0]) / 2;
const cellY = botTabRow[i][1];
const x1 = botTabRow[i][0];
const x2 = botTabRow[i + 1][0];
for (let i = 0; i < rows[holeBottomRowKey].length - 1; i++) {
const x = (rows[holeBottomRowKey][i][0] + rows[holeBottomRowKey][i + 1][0]) / 2;
const cellY = rows[holeBottomRowKey][i][1];
const x1 = rows[holeBottomRowKey][i][0];
const x2 = rows[holeBottomRowKey][i + 1][0];
const wallY = packMaxY;
const y = bottomEdge;
const flip = cellY < wallY ? -1 : 1;
@@ -285,6 +323,31 @@ export function drawPreview(positions, cellSize) {
const debugTri = useFullCircles ? { apex: { x, y: wallY }, left, right } : null;
bmsHolePositions.push({ x, y, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri });
}
if (layoutType === 'vertical') {
const botIsEven = vertColPitch === 0 || (botTabRow[0][0] - minX) < vertColPitch / 2;
if (!botIsEven) {
// Use bottomEdge for all types so corner aligns with interior bottom holes
const botExtY = bottomEdge;
// Left corner: continue interior hole pattern (botTabPitch/2 = one colPitch to the left)
bmsHolePositions.push({ x: botTabRow[0][0] - botTabPitch / 2, y: botExtY, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
// Right corner only if an even-col neighbor exists to the right (odd S = yes, even S = no)
const botRightNeighX = botTabRow[botTabRow.length - 1][0] + vertColPitch;
if (_allXSorted.some(x => Math.abs(x - botRightNeighX) < 0.5)) {
bmsHolePositions.push({ x: botTabRow[botTabRow.length - 1][0] + botTabPitch / 2, y: botExtY, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
}
} else if (vertColPitch > 0 && botTabRow.length >= 2) {
// Even-col row (visual top for non-FC): add right corner when an odd col extends beyond the last even col
const botRightNeighX = botTabRow[botTabRow.length - 1][0] + vertColPitch;
if (_allXSorted.some(x => Math.abs(x - botRightNeighX) < 0.5)) {
bmsHolePositions.push({ x: botTabRow[botTabRow.length - 1][0] + botTabPitch / 2, y: bottomEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
}
}
} else if (layoutType !== 'grid') {
const botTabExtraRight = botTabRow.length < 2 || (botTabRow[0][0] - minX) < botTabPitch / 4;
bmsHolePositions.push(botTabExtraRight
? { x: botTabRow[botTabRow.length - 1][0] + botTabPitch / 2, y: bottomEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null }
: { x: botTabRow[0][0] - botTabPitch / 2, y: bottomEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
}
}
ctx.fillStyle = '#1e293b';
@@ -321,20 +384,19 @@ export function drawPreview(positions, cellSize) {
ctx.strokeStyle = 'rgba(255, 193, 7, 0.9)';
ctx.lineWidth = 1.5 / zoom;
const tabWidthMm = parseFloat(document.getElementById('tabWidth').value) || 4.0;
const tabDepthMm = parseFloat(document.getElementById('tabDepth').value) || 1.0;
const tabWidthMm = parseFloat(document.getElementById('height').value) || 10.0;
const tabWidth = tabWidthMm * scale;
const tabHeight = tabDepthMm * scale;
const tabHeight = 1.0 * scale;
for (const hole of bmsHolePositions) {
const cx = (hole.x - minX + r + spacing) * scale + offsetX;
if (hole.y > maxY) {
ctx.fillRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
ctx.strokeRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
} else {
if (hole.y < minY) {
ctx.fillRect(cx - tabWidth / 2, offsetY, tabWidth, tabHeight);
ctx.strokeRect(cx - tabWidth / 2, offsetY, tabWidth, tabHeight);
} else {
ctx.fillRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
ctx.strokeRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
}
}
} else if (useFullCircles) {

View File

@@ -33,9 +33,11 @@ export function toggleBmsDiameter() {
const bmsType = document.getElementById('bmsHolesType').value;
const diameterGroup = document.getElementById('bmsHoleDiameterGroup');
const tabDimsGroup = document.getElementById('tabDimensionsGroup');
const tabOverlapSideGroup = document.getElementById('tabOverlapSideGroup');
diameterGroup.style.display =
(bmsType === 'halfcircles' || bmsType === 'fullcircles') ? 'block' : 'none';
tabDimsGroup.style.display = (bmsType === 'tabs') ? 'grid' : 'none';
if (tabOverlapSideGroup) tabOverlapSideGroup.style.display = (bmsType === 'tabs') ? 'block' : 'none';
}
export function initCustomSelects() {

View File

@@ -3,6 +3,13 @@ const HASH_PREFIX = '#config=';
const ALLOWED_LAYOUTS = new Set(['grid', 'honeycomb', 'vertical']);
const ALLOWED_BMS_TYPES = new Set(['off', 'halfcircles', 'fullcircles', 'tabs']);
const ALLOWED_TAB_OVERLAP_SIDES = new Set(['off', 'top', 'bottom', 'left', 'right']);
function normalizeTabOverlapSide(value) {
if (value === 'top' || value === 'bottom' || value === 'off') return value;
if (value === 'left' || value === 'right') return 'off';
throw new Error('Invalid tab overlap side');
}
const ALLOWED_PACK_MODES = new Set(['sp', 'mm']);
const ALLOWED_BUSBAR_FORMATS = new Set(['step', 'dxf']);
@@ -58,8 +65,12 @@ function normalizeBusbar(raw, index) {
});
const face = raw.face === 'bottom' ? 'bottom' : 'top';
return { id, name, color, thickness, cellIndices, face };
const overlapEnabled = raw.overlapEnabled == null ? false : requireBoolean(raw.overlapEnabled, `busbars.list[${index}].overlapEnabled`);
const overlapSize = raw.overlapSize == null
? 10
: requireFiniteNumber(Number(raw.overlapSize), `busbars.list[${index}].overlapSize`);
if (overlapSize <= 0) throw new Error(`Invalid overlap size at index ${index}`);
return { id, name, color, thickness, cellIndices, face, overlapEnabled, overlapSize };
}
function normalizeConfig(raw) {
@@ -86,9 +97,15 @@ function normalizeConfig(raw) {
const bmsType = requireString(bms.type, 'bms.type');
if (!ALLOWED_BMS_TYPES.has(bmsType)) throw new Error('Invalid BMS type');
const rawTabOverlapSide = requireString(bms.tabOverlapSide ?? 'off', 'bms.tabOverlapSide');
if (!ALLOWED_TAB_OVERLAP_SIDES.has(rawTabOverlapSide)) throw new Error('Invalid tab overlap side');
const tabOverlapSide = normalizeTabOverlapSide(rawTabOverlapSide);
const busbarFormat = requireString(busbars.format, 'busbars.format');
if (!ALLOWED_BUSBAR_FORMATS.has(busbarFormat)) throw new Error('Invalid busbar format');
const cellCutoutEnabled = busbars.cellCutoutEnabled == null
? false
: requireBoolean(busbars.cellCutoutEnabled, 'busbars.cellCutoutEnabled');
const list = Array.isArray(busbars.list)
? busbars.list.map((bb, i) => normalizeBusbar(bb, i))
@@ -122,11 +139,14 @@ function normalizeConfig(raw) {
type: bmsType,
holeDiameter: requireFiniteNumber(Number(bms.holeDiameter), 'bms.holeDiameter'),
tabWidth: requireFiniteNumber(Number(bms.tabWidth), 'bms.tabWidth'),
tabLength: requireFiniteNumber(Number(bms.tabLength ?? 10), 'bms.tabLength'),
tabDepth: requireFiniteNumber(Number(bms.tabDepth), 'bms.tabDepth'),
tabOverlapSide,
},
busbars: {
format: busbarFormat,
activeId,
cellCutoutEnabled,
list,
},
};
@@ -187,11 +207,14 @@ export function captureConfig(getPackMode, busbarSnapshot) {
type: readString('bmsHolesType', 'fullcircles'),
holeDiameter: readNumber('bmsHoleDiameter', 4.0),
tabWidth: readNumber('tabWidth', 4.0),
tabLength: readNumber('tabLength', 10.0),
tabDepth: readNumber('tabDepth', 1.0),
tabOverlapSide: normalizeTabOverlapSide(readString('tabOverlapSide', 'off')),
},
busbars: {
format: readString('busbarFormat', 'step'),
activeId: busbarSnapshot?.activeId ?? null,
cellCutoutEnabled: readBool('busbarCellCutoutEnabled', false),
list: Array.isArray(busbarSnapshot?.list)
? busbarSnapshot.list.map(bb => ({ ...bb, face: bb.face === 'bottom' ? 'bottom' : 'top' }))
: [],

View File

@@ -1260,12 +1260,40 @@ input[type="checkbox"]:focus {
gap: 8px;
}
.busbar-swatch {
width: 16px;
height: 16px;
border-radius: 4px;
.busbar-color-wrap {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.04);
}
.busbar-color {
width: 100%;
height: 100%;
border: none;
padding: 0;
background: transparent;
cursor: pointer;
}
.busbar-color::-webkit-color-swatch-wrapper {
padding: 0;
}
.busbar-color::-webkit-color-swatch {
border: none;
border-radius: 0;
}
.busbar-color::-moz-color-swatch {
border: none;
border-radius: 0;
}
.busbar-name {
@@ -1310,6 +1338,7 @@ input[type="checkbox"]:focus {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
padding-left: 24px;
@@ -1317,6 +1346,47 @@ input[type="checkbox"]:focus {
color: #94a3b8;
}
.busbar-overlap-label {
display: inline-flex;
align-items: center;
gap: 6px;
color: #cbd5e1;
cursor: pointer;
user-select: none;
}
.busbar-overlap {
accent-color: #6495ed;
}
.busbar-overlap-size-label {
display: inline-flex;
align-items: center;
gap: 6px;
color: #94a3b8;
font-size: 0.95em;
}
.busbar-overlap-size {
width: 64px;
padding: 3px 6px;
font-size: 0.9em;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(100, 149, 237, 0.2);
border-radius: 4px;
color: #e2e8f0;
}
.busbar-overlap-size:focus {
outline: none;
border-color: rgba(100, 149, 237, 0.6);
}
.busbar-overlap-size:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.busbar-thickness-label {
display: flex;
align-items: center;