Compare commits

...

9 Commits

Author SHA1 Message Date
Finn Tews
74afe4dfce fixed overlap 2026-05-09 00:43:39 +02:00
Finn Tews
1eda1ea695 added warnng 2026-05-08 23:33:39 +02:00
Finn Tews
85f601984f Message text right top edited 2026-05-08 22:55:25 +02:00
Finn Tews
708b527598 final release 2026-05-08 22:37:31 +02:00
Finn Tews
feac4235b2 Fixed the edge tabs overhang 2026-05-08 21:09:06 +02:00
Finn Tews
ae2e631cc7 added edge tabs and a lot of bug fixes abizt overlaps
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 08:39:18 +02:00
Finn Tews
80e5c17ead fixed top only in busbar tab
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 23:15:12 +02:00
Finn Tews
60edfe11d2 edited readme 2026-04-27 23:07:01 +02:00
Finn Tews
0598c25e42 edited readme 2026-04-27 23:06:39 +02:00
29 changed files with 1957 additions and 189 deletions

View File

@@ -1,8 +1,10 @@
# Battery Builder
A fork of waak´s https://github.com/waak86/battery-builder
A browser tool for designing 3D printable cell holders and laser or plasma cut busbars for custom battery packs. Everything runs client side in the browser, no server, no install.
Live at https://generate.waak.me/
Live at https://generate.scooter-labs.com
## What you get

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

@@ -18,11 +18,28 @@
<div class="container">
<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 class="header-title">
<h1>Cell Holder Generator</h1>
<p class="waak-credit">Huge thanks to <a href="https://t.me/waak86" target="_blank" rel="noopener noreferrer">waak</a> for the original project.</p>
</div>
<div class="header-right">
<a class="gitea-link" href="https://gitea.overkill.cc/morss12/battery-builder" target="_blank" rel="noopener noreferrer" title="View source on Gitea">
<svg class="gitea-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M11.9999 1C6.0589 1 1.1999 5.859 1.1999 11.8C1.1999 16.554 4.2629 20.579 8.5569 22.053C9.1069 22.155 9.3089 21.818 9.3089 21.531C9.3089 21.272 9.2989 20.483 9.2949 19.641C6.3169 20.298 5.6699 18.261 5.6699 18.261C5.1699 16.976 4.4469 16.64 4.4469 16.64C3.4549 15.964 4.5219 15.978 4.5219 15.978C5.6209 16.053 6.1999 17.104 6.1999 17.104C7.1749 18.791 8.7029 18.306 9.3289 18.029C9.4289 17.322 9.7099 16.838 10.0199 16.563C7.6459 16.285 5.1489 15.366 5.1489 11.274C5.1489 10.056 5.5769 9.061 6.2199 8.282C6.1069 8.004 5.7309 6.868 6.3279 5.336C6.3279 5.336 7.2449 5.041 9.2829 6.487C10.1369 6.244 11.0709 6.122 11.9999 6.118C12.9289 6.122 13.8629 6.244 14.7179 6.487C16.7549 5.041 17.6709 5.336 17.6709 5.336C18.2689 6.868 17.8929 8.004 17.7799 8.282C18.4239 9.061 18.8499 10.056 18.8499 11.274C18.8499 15.376 16.3489 16.283 13.9679 16.554C14.3559 16.892 14.7019 17.558 14.7019 18.576C14.7019 20.038 14.6889 21.222 14.6889 21.531C14.6889 21.82 14.8879 22.159 15.4459 22.052C19.7369 20.576 22.7999 16.553 22.7999 11.8C22.7999 5.859 17.9409 1 11.9999 1Z"/></svg>
Source
</a>
<a class="gitea-link" href="https://github.com/waak86/battery-builder" target="_blank" rel="noopener noreferrer" title="View original project on GitHub">
<svg class="gitea-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.604-3.369-1.341-3.369-1.341-.454-1.154-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0 1 12 6.836a9.59 9.59 0 0 1 2.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.202 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z"/></svg>
Original Source
</a>
</div>
</div>
<p class="subtitle">Generate custom 3D printable cell holders with STEP export</p>
<div class="warning-banner">
<svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>This project is under construction and has many bugs! Always check your parameters and the DXF and STEP files twice!</span>
</div>
<div class="main-layout">
<div class="config-sidebar">
<nav class="sidebar-tabs" role="tablist" data-tabs>
@@ -154,14 +171,23 @@
</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">
<label>Tab Length (mm)</label>
<input type="number" id="tabLength" value="10.0" min="1" step="0.5">
</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>Edge Cut Width (mm)</label>
<input type="number" id="tabWidth" value="4.0" min="1" step="0.5">
</div>
</div>
<div class="form-group" id="tabOverlapSideGroup" style="display:none;">
<label>Tab Overlap Side</label>
<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 +199,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>
@@ -191,6 +221,11 @@
<button class="btn-secondary" id="addTopBusbarBtn">+ Top Busbar</button>
<button class="btn-secondary" id="addBottomBusbarBtn">+ Bottom Busbar</button>
</div>
<div class="panel-divider" aria-hidden="true"></div>
<div class="order-header">Copper Sheet Calculator</div>
<div id="orderContent">
<p class="order-placeholder">Generate a preview first to see sheet requirements.</p>
</div>
</section>
</div>

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",
"hash": "8727c2cc",
"configHash": "cddbe005",
"lockfileHash": "6cdf80c6",
"browserHash": "40f95f93",
"lockfileHash": "6e69140d",
"browserHash": "80e0ce3b",
"optimized": {
"jszip": {
"src": "../../jszip/dist/jszip.min.js",
"file": "jszip.js",
"fileHash": "26c0effe",
"fileHash": "d6368be4",
"needsInterop": true
}
},

View File

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

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,289 @@ 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) {
// Skip if the anchor is too far horizontally from the tab centre — this
// means the busbar doesn't straddle the slot and the arm would require a
// hard 90° bend (e.g. a single-column vertical busbar next to a tab).
// Valid connections have an edge midpoint exactly at tab.x → deltaX ≈ 0.
// Invalid single-column case has deltaX ≈ ±pitch/2 > cellRadius.
if (Math.abs(best.deltaX) > cellRadius) continue;
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);
@@ -202,9 +485,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('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);
@@ -224,7 +535,11 @@ export function updatePreview(resetView = false) {
const actualHeight = maxY - minY + cellSize + spacing * 2;
if (positions.length >= 2) {
stats.textContent = `${positions.length} cells • ${actualWidth.toFixed(0)}×${actualHeight.toFixed(0)} mm`;
const areaCm2 = (actualWidth * actualHeight / 100).toFixed(0);
stats.textContent = `${positions.length} cells • ${actualWidth.toFixed(0)}×${actualHeight.toFixed(0)} mm • ${areaCm2} cm²`;
const s = Math.max(1, Math.round(parseFloat(document.getElementById('series')?.value) || 1));
lastPreviewState = { positions, cellSize, spacing, seriesCount: s };
refreshOrderFromLastState();
}
} catch (error) {
console.error('Preview error:', error);
@@ -448,14 +763,15 @@ export async function generateLayout() {
}
}
const tabWidth = parseFloat(document.getElementById('tabWidth').value) || 4.0;
const tabDepth = parseFloat(document.getElementById('tabDepth').value) || 1.0;
const edgeCutWidth = parseFloat(document.getElementById('tabWidth')?.value) || 4.0;
const tabLength = parseFloat(document.getElementById('tabLength')?.value) || 10.0;
const tabOverlapSide = document.getElementById('tabOverlapSide')?.value || 'off';
const config = {
cellSize, spacing, height, terminalDiameter, terminalDepth,
coverThickness, roundedCorners, bmsHoles, ledgeWidth,
filletBms, circleHoleOffset, useTabs, useFullCircles, bmsHoleDiameter,
tabWidth, tabDepth,
tabWidth: edgeCutWidth, tabLength, tabDepth: 1.0, tabOverlapSide, layoutType,
};
const holderShape = create3DModel(positions, config);
@@ -467,9 +783,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: edgeCutWidth - 1,
tabOverlapSide,
overlapLength: height,
layoutType,
});
for (let i = 0; i < busbarStore.list.length; i++) {
const bb = busbarStore.list[i];
@@ -492,10 +836,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 +856,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 +870,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 +892,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 +922,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 +946,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 +976,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 +1015,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,470 @@ 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 inferVerticalPitch(positions) {
const epsilon = 1e-3;
const cols = new Map();
for (const pos of positions) {
const key = pos[0].toFixed(4);
if (!cols.has(key)) cols.set(key, []);
cols.get(key).push(pos[1]);
}
let pitch = Infinity;
for (const colYs of cols.values()) {
colYs.sort((a, b) => a - b);
for (let index = 1; index < colYs.length; index++) {
const delta = colYs[index] - colYs[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, cellRadius, spacing) {
if (layoutType === 'vertical') {
return computeEdgeOverlapFeaturesVertical(cellIndices, positions, overlapLength, cellRadius, spacing);
}
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: [] };
}
// Skip overlap if the busbar spans only one column (all cells share the same
// X-coordinate, rounded to 0.1 mm). A single-column busbar's internal edges
// are all vertical, so a horizontal arm would be at 90° to every connection
// and would visually overlap adjacent busbars.
const uniqueXCount = new Set(selected.map(e => Math.round(e.pos[0] * 10))).size;
if (uniqueXCount < 2) {
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 computeBoundaryRoundoverFeaturesVertical(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 verticalPitch = inferVerticalPitch(positions);
if (verticalPitch <= epsilon) {
return { extraPads: [], extraSegments: [] };
}
const yTolerance = Math.max(0.5, verticalPitch * 0.25);
const xTolerance = 1e-3;
const hasVerticalNeighbor = (entry, direction) => positions.some((pos) => {
if (Math.abs(pos[0] - entry.pos[0]) > xTolerance) return false;
const deltaY = pos[1] - entry.pos[1];
if (direction === 'top' && deltaY >= -epsilon) return false;
if (direction === 'bottom' && deltaY <= epsilon) return false;
return Math.abs(Math.abs(deltaY) - verticalPitch) <= yTolerance;
});
const cols = new Map();
for (const entry of selected) {
const key = entry.pos[0].toFixed(4);
if (!cols.has(key)) cols.set(key, []);
cols.get(key).push(entry);
}
const topBoundary = [];
const bottomBoundary = [];
for (const colEntries of cols.values()) {
colEntries.sort((a, b) => a.pos[1] - b.pos[1]);
topBoundary.push(colEntries[0]);
bottomBoundary.push(colEntries[colEntries.length - 1]);
}
const topExposed = topBoundary.filter((entry) => !hasVerticalNeighbor(entry, 'top'));
const bottomExposed = bottomBoundary.filter((entry) => !hasVerticalNeighbor(entry, 'bottom'));
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 === 'top' ? -1 : 1;
const ordered = entries.slice().sort((a, b) => a.pos[0] - b.pos[0]);
for (let index = 0; index < ordered.length - 1; index++) {
const left = ordered[index];
const right = ordered[index + 1];
const fillKey = `boundary_round_${side}_${index}`;
const fillPos = [
(left.pos[0] + right.pos[0]) / 2,
(left.pos[1] + right.pos[1]) / 2 + direction * outwardShift,
];
extraPads.push({ key: fillKey, pos: fillPos, radius: fillRadius });
extraSegments.push({ from: fillPos, to: left.pos, fromKey: fillKey, toKey: `c${left.index}`, radius: fillRadius });
extraSegments.push({ from: fillPos, to: right.pos, fromKey: fillKey, toKey: `c${right.index}`, radius: fillRadius });
}
};
addSideRoundovers(topExposed, 'top');
addSideRoundovers(bottomExposed, 'bottom');
return { extraPads, extraSegments };
}
function computeEdgeOverlapFeaturesVertical(cellIndices, positions, overlapLength, cellRadius, spacing) {
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: [] };
}
// Measure the X pitch between adjacent columns in the full layout.
const uniqueColXs = [...new Set(positions.map(p => Math.round(p[0] * 10)))]
.sort((a, b) => a - b);
let colPitch = Infinity;
for (let i = 1; i < uniqueColXs.length; i++) {
const d = (uniqueColXs[i] - uniqueColXs[i - 1]) / 10;
if (d > 1e-3) colPitch = Math.min(colPitch, d);
}
if (!Number.isFinite(colPitch) || colPitch <= 1e-3) {
return { extraPads: [], extraSegments: [] };
}
const colTolerance = colPitch * 0.3;
// Group selected cells by column (keyed by rounded X*10).
const colMap = new Map();
for (const entry of selected) {
const key = Math.round(entry.pos[0] * 10);
if (!colMap.has(key)) colMap.set(key, []);
colMap.get(key).push(entry);
}
const sortedColKeys = [...colMap.keys()].sort((a, b) => a - b);
const leftColX = sortedColKeys[0] / 10;
const rightColX = sortedColKeys[sortedColKeys.length - 1] / 10;
// Count how many boundary-column cells have no full-layout column neighbor.
const leftColCells = colMap.get(sortedColKeys[0]);
const rightColCells = colMap.get(sortedColKeys[sortedColKeys.length - 1]);
const countExposed = (colCells, side) => colCells.filter((entry) => {
const neighborX = side === 'left' ? leftColX - colPitch : rightColX + colPitch;
return !positions.some((p) => Math.abs(p[0] - neighborX) < colTolerance);
}).length;
const leftExposedCount = countExposed(leftColCells, 'left');
const rightExposedCount = countExposed(rightColCells, 'right');
// Skip if the busbar is only a single column — a single-column spine that
// runs straight through all pads adds no useful material above what the
// pad-and-edge geometry already provides.
// (We still proceed when there are multiple columns.)
let chosenSide, boundaryCells, boundaryColX;
if (leftExposedCount > rightExposedCount) {
chosenSide = 'left';
boundaryCells = leftColCells;
boundaryColX = leftColX;
} else if (rightExposedCount > leftExposedCount) {
chosenSide = 'right';
boundaryCells = rightColCells;
boundaryColX = rightColX;
} else if (leftExposedCount > 0) {
// Tie: prefer left.
chosenSide = 'left';
boundaryCells = leftColCells;
boundaryColX = leftColX;
} else {
return { extraPads: [], extraSegments: [] };
}
const extension = Number.isFinite(Number(overlapLength)) && Number(overlapLength) > 0
? Number(overlapLength)
: 10;
// Place the spine relative to the pack boundary (not the cell centre) so the
// overlap tab is clearly visible outside the pack rectangle.
const packLeft = Math.min(...positions.map(p => p[0])) - cellRadius - spacing;
const packRight = Math.max(...positions.map(p => p[0])) + cellRadius + spacing;
const spineX = chosenSide === 'left'
? packLeft - extension
: packRight + extension;
const extraPads = [];
const extraSegments = [];
const sortedBoundaryCells = boundaryCells.slice().sort((a, b) => a.pos[1] - b.pos[1]);
const overlapPads = sortedBoundaryCells.map((entry, index) => {
const key = `edge_overlap_${index}`;
const overlapPos = [spineX, 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 };
});
// Connect adjacent spine pads with a vertical segment and add fill pads.
for (let index = 0; index < overlapPads.length - 1; index++) {
const topPad = overlapPads[index];
const bottomPad = overlapPads[index + 1];
const topCell = sortedBoundaryCells[index];
const bottomCell = sortedBoundaryCells[index + 1];
extraSegments.push({
from: topPad.pos,
to: bottomPad.pos,
fromKey: topPad.key,
toKey: bottomPad.key,
});
const fillKey = `edge_overlap_fill_${index}`;
const fillPos = [
(topPad.pos[0] + bottomPad.pos[0] + topCell.pos[0] + bottomCell.pos[0]) / 4,
(topPad.pos[1] + bottomPad.pos[1] + topCell.pos[1] + bottomCell.pos[1]) / 4,
];
extraPads.push({ key: fillKey, pos: fillPos });
extraSegments.push({ from: fillPos, to: topPad.pos, fromKey: fillKey, toKey: topPad.key });
extraSegments.push({ from: fillPos, to: bottomPad.pos, fromKey: fillKey, toKey: bottomPad.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 +643,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 +661,20 @@ export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRad
};
});
return { padIndices: cellIndices.slice(), edges: roundedEdges, blocked: null };
const overlapFeatures = overlapEnabled
? computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapSize, cellRadius, spacing)
: { extraPads: [], extraSegments: [] };
const roundoverFeatures = layoutType === 'vertical'
? computeBoundaryRoundoverFeaturesVertical(cellIndices, positions, padRadius)
: 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,19 +135,100 @@ 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();
}
}
}
// 3b. Fill four-cell square interstice (grid layout).
// For every diagonal pair (in triAdj but not adjCaps) that has exactly
// 2 common direct (capsule) neighbours, those 4 cells form a 2×2 square.
// Fill a circle at the centroid whose radius spans the gap.
{
const directSet = new Set(adjCaps.map(([a, b]) => `${Math.min(a,b)}_${Math.max(a,b)}`));
const visited4 = new Set();
off.fillStyle = opaqueColor;
for (let a = 0; a < nCells; a++) {
for (const b of triAdj[a]) {
if (b <= a) continue;
if (directSet.has(`${Math.min(a,b)}_${Math.max(a,b)}`)) continue; // skip direct neighbours
// ab is diagonal; find their common direct neighbours
const common = [];
for (const c of triAdj[a]) {
if (c === b) continue;
if (!directSet.has(`${Math.min(a,c)}_${Math.max(a,c)}`)) continue;
if (!triAdj[b].has(c)) continue;
if (!directSet.has(`${Math.min(b,c)}_${Math.max(b,c)}`)) continue;
common.push(c);
}
if (common.length < 2) continue;
const [c, d] = common.sort((x, y) => x - y);
const quadKey = [a, b, c, d].sort((x, y) => x - y).join('_');
if (visited4.has(quadKey)) continue;
visited4.add(quadKey);
const pa = positions[cellIndices[a]];
const pb = positions[cellIndices[b]];
const pc = positions[cellIndices[c]];
const pd = positions[cellIndices[d]];
if (!pa || !pb || !pc || !pd) continue;
const qcx = (pa[0] + pb[0] + pc[0] + pd[0]) / 4;
const qcy = (pa[1] + pb[1] + pc[1] + pd[1]) / 4;
const distToCell = Math.hypot(pa[0] - qcx, pa[1] - qcy);
const fillR = Math.max(0.5, distToCell - padRadius);
off.beginPath();
off.arc(toScreenX(qcx), toScreenY(qcy), fillR * t.scale, 0, Math.PI * 2);
off.fill();
}
}
}
// 4. Obstacle-avoidance detour waypoints (spanning-tree edges with bends).
off.strokeStyle = opaqueColor;
off.lineWidth = 2 * padRadiusScreen;
off.lineWidth = 2 * padRadiusScreen();
off.lineCap = 'round';
for (const edge of geom.edges) {
if (edge.waypoints.length === 0) continue;
@@ -142,6 +240,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 +267,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);
@@ -123,7 +142,7 @@ export function initBusbarUI({ onDownloadSingle, onDownloadAll, onFaceFilterChan
b.classList.toggle('active', b.dataset.filter === faceFilter);
});
if (bottomFaceWrap) bottomFaceWrap.hidden = (faceFilter === 'top');
if (onFaceFilterChange) onFaceFilterChange();
if (onFaceFilterChange) onFaceFilterChange(faceFilter);
renderBusbarList();
});
});

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

@@ -2,10 +2,11 @@ import { canvasState } from './state.js';
import { initOC } from './oc.js';
import { toggleBmsDiameter, initCustomSelects, showStatus } from './ui.js';
import { drawPreview } from './preview.js';
import { updatePreview, generateLayout, redrawBusbarOverlay, downloadSingleBusbar, downloadAllBusbarsZip } from './app.js';
import { updatePreview, generateLayout, redrawBusbarOverlay, downloadSingleBusbar, downloadAllBusbarsZip, setOrderUpdateCallback, refreshOrderFromLastState } from './app.js';
import { busbarStore } from './busbars.js';
import { initBusbarUI, renderBusbarList } from './busbar-ui.js';
import { captureConfig, encodeConfigToHash, decodeHashToConfig } from './url-config.js';
import { renderOrderSection } from './order.js';
const CLICK_PIXEL_THRESHOLD = 4;
const URL_SYNC_DEBOUNCE_MS = 250;
@@ -18,14 +19,13 @@ function scaleCanvasById(id) {
const canvas = document.getElementById(id);
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
// Only update the drawing buffer to match the current CSS-rendered size × DPR.
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
}
function scaleCanvasForDPI() {
@@ -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;
@@ -625,6 +627,8 @@ function wireSidebarTabs() {
async function initializeApp() {
scaleCanvasForDPI();
initCustomSelects();
setOrderUpdateCallback(renderOrderSection);
busbarStore.subscribe(() => refreshOrderFromLastState());
wireSidebarTabs();
wirePackMode();
@@ -642,11 +646,14 @@ async function initializeApp() {
initBusbarUI({
onDownloadSingle: downloadSingleBusbar,
onDownloadAll: downloadAllBusbarsZip,
onFaceFilterChange() {
requestAnimationFrame(() => {
onFaceFilterChange(filter) {
// Double rAF: first frame applies the hidden/visible DOM change,
// second frame reads the settled flex layout dimensions.
requestAnimationFrame(() => requestAnimationFrame(() => {
scaleCanvasById('preview');
if (filter === 'both') scaleCanvasById('preview-bottom');
redrawBusbarOverlay();
});
}));
},
});
renderBusbarList();
@@ -664,6 +671,22 @@ async function initializeApp() {
await initOC();
// Keep canvas drawing buffers in sync whenever their CSS size changes
// (e.g. window resize, flex layout settling, face-filter toggles).
const canvasResizeObserver = new ResizeObserver(() => {
scaleCanvasForDPI();
updatePreview(false);
});
const previewCanvas = document.getElementById('preview');
const previewBottomCanvas = document.getElementById('preview-bottom');
if (previewCanvas) canvasResizeObserver.observe(previewCanvas);
if (previewBottomCanvas) canvasResizeObserver.observe(previewBottomCanvas);
window.addEventListener('resize', () => {
scaleCanvasForDPI();
updatePreview(false);
});
setTimeout(() => {
// Force a fresh render after both OC init and config load are complete.
// This ensures viewTransform and geometries are in sync with loaded busbars.

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

124
src/order.js Normal file
View File

@@ -0,0 +1,124 @@
const WIDTHS = [50, 100, 150, 200, 300];
const LENGTHS = [300, 1000, 1500];
function calcCuts(sheetW, sheetL, busbarW, busbarH) {
if (busbarW <= 0 || busbarH <= 0) return 0;
const orientA = Math.floor(sheetW / busbarW) * Math.floor(sheetL / busbarH);
const orientB = Math.floor(sheetW / busbarH) * Math.floor(sheetL / busbarW);
return Math.max(orientA, orientB);
}
export function renderOrderSection({ busbarSheets, busbarsNeeded }) {
const container = document.getElementById('orderContent');
if (!container) return;
const defined = busbarSheets.length;
const nonEmpty = busbarSheets.filter(b => !b.empty);
const emptyOnes = busbarSheets.filter(b => b.empty);
const missing = busbarsNeeded - defined; // negative = too many defined, 0 = exact, positive = missing
// ── Warnings ─────────────────────────────────────────────────────────────
let warnings = '';
if (defined === 0) {
warnings += `<div class="order-warning">No busbars defined. Add busbars in the list above and assign cells by clicking them in the preview.</div>`;
} else {
if (missing > 0) {
warnings += `<div class="order-warning">${missing} busbar${missing > 1 ? 's' : ''} missing &mdash; need ${busbarsNeeded}, have ${defined}.</div>`;
}
if (emptyOnes.length > 0) {
const names = emptyOnes.map(b => `<strong>${escHtml(b.name)}</strong>`).join(', ');
warnings += `<div class="order-warning">${names} ${emptyOnes.length === 1 ? 'has' : 'have'} no cells assigned &mdash; click cells in the preview to assign them.</div>`;
}
}
// ── Nothing to calculate ─────────────────────────────────────────────────
if (nonEmpty.length === 0) {
container.innerHTML = warnings +
`<p class="order-placeholder">Assign cells to busbars to calculate sheet requirements.</p>`;
return;
}
// ── Max sheet dimensions (largest busbar drives the order size) ───────────
const maxW = Math.max(...nonEmpty.map(b => b.w));
const maxH = Math.max(...nonEmpty.map(b => b.h));
const totalSheets = busbarsNeeded; // what we actually need to order
const totalAreaCm2 = (maxW * maxH * totalSheets / 100).toFixed(1);
const singleAreaCm2 = (maxW * maxH / 100).toFixed(1);
// ── Per-busbar size breakdown (only when sizes differ) ────────────────────
const sizesVary = nonEmpty.some(b => Math.abs(b.w - maxW) > 0.5 || Math.abs(b.h - maxH) > 0.5);
let perBusbarHtml = '';
if (sizesVary) {
perBusbarHtml = `<div class="order-busbar-sizes">`;
for (const b of nonEmpty) {
perBusbarHtml += `<div class="order-busbar-size-row">
<span class="order-label">${escHtml(b.name)}</span>
<span class="order-value">${b.w.toFixed(0)} &times; ${b.h.toFixed(0)} mm &nbsp;<span class="order-muted">(${(b.w * b.h / 100).toFixed(1)} cm²)</span></span>
</div>`;
}
perBusbarHtml += `</div>`;
}
// ── Sheet table ───────────────────────────────────────────────────────────
const rows = [];
for (const w of WIDTHS) {
for (const l of LENGTHS) {
const cuts = calcCuts(w, l, maxW, maxH);
const sheets = cuts > 0 ? Math.ceil(totalSheets / cuts) : null;
rows.push({ w, l, cuts, sheets });
}
}
const fittingSheets = rows.filter(r => r.sheets !== null).map(r => r.sheets);
const bestSheets = fittingSheets.length > 0 ? Math.min(...fittingSheets) : null;
let tableHtml = `
<table class="order-table">
<thead>
<tr>
<th>Sheet (W&times;L mm)</th>
<th>Cuts / sheet</th>
<th>Sheets to buy</th>
</tr>
</thead>
<tbody>
`;
for (const { w, l, cuts, sheets } of rows) {
const noFit = cuts === 0;
const isBest = !noFit && sheets === bestSheets;
const cls = noFit ? 'order-row-nofit' : (isBest ? 'order-row-best' : '');
tableHtml += `
<tr class="${cls}">
<td>${w} &times; ${l}</td>
<td>${noFit ? '&mdash;' : cuts}</td>
<td>${noFit ? '&mdash;' : sheets}</td>
</tr>
`;
}
tableHtml += `</tbody></table>`;
// ── Summary ───────────────────────────────────────────────────────────────
const summaryHtml = `
<div class="order-summary">
<div class="order-summary-row">
<span class="order-label">Largest busbar</span>
<span class="order-value">${maxW.toFixed(0)} &times; ${maxH.toFixed(0)} mm</span>
</div>
<div class="order-summary-row">
<span class="order-label">Sheets needed</span>
<span class="order-value">${totalSheets}</span>
</div>
<div class="order-summary-row">
<span class="order-label">Total copper area</span>
<span class="order-value">${totalAreaCm2} cm² <span class="order-muted">(${singleAreaCm2} cm² each)</span></span>
</div>
</div>
`;
container.innerHTML = warnings + summaryHtml + perBusbarHtml + tableHtml;
}
function escHtml(s) {
return String(s).replace(/[&<>"']/g, ch => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]
));
}

View File

@@ -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('tabWidth')?.value) || 4.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

@@ -10,26 +10,29 @@ body {
min-height: 100vh;
color: #e2e8f0;
line-height: 1.6;
padding: 48px 24px;
padding: 12px 24px;
}
.container {
max-width: 1600px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
transition: max-width 0.3s ease, padding 0.3s ease;
}
/* Header */
.header-row {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 10px;
gap: 12px;
margin-bottom: 4px;
}
h1 {
font-size: 2.5em;
font-size: 1.6em;
font-weight: 700;
color: #fff;
margin-bottom: 0;
@@ -45,26 +48,108 @@ h1:hover {
.subtitle {
color: #94a3b8;
font-size: 1em;
margin-bottom: 32px;
font-size: 0.88em;
margin-bottom: 6px;
transition: color 0.3s ease;
animation: fadeInUp 0.6s ease-out 0.1s both;
}
.repo-credit {
margin: 0;
padding: 10px 14px;
color: #dbeafe;
font-size: 0.95em;
padding: 5px 10px;
color: #a7f3d0;
font-size: 0.85em;
font-weight: 600;
line-height: 1.5;
text-align: center;
max-width: 560px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(59, 130, 246, 0.12));
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(110, 231, 183, 0.35);
border-radius: 10px;
border-radius: 8px;
animation: fadeInUp 0.6s ease-out 0.15s both;
}
.header-right {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.header-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.waak-credit {
margin: 0;
color: #6ee7b7;
font-size: 0.8em;
opacity: 0.8;
}
.waak-credit a {
color: #a7f3d0;
font-weight: 600;
text-decoration: none;
}
.waak-credit a:hover {
text-decoration: underline;
}
.warning-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin: 0 auto 8px;
padding: 6px 14px;
max-width: 700px;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.45);
border-radius: 8px;
color: #fde68a;
font-size: 0.88em;
font-weight: 500;
text-align: center;
line-height: 1.4;
}
.warning-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: #fbbf24;
}
.gitea-link {
display: inline-flex;
align-items: center;
gap: 6px;
align-self: flex-end;
color: #a7f3d0;
font-size: 0.85em;
font-weight: 600;
text-decoration: none;
padding: 5px 10px;
border: 1px solid rgba(110, 231, 183, 0.35);
border-radius: 8px;
background: rgba(16, 185, 129, 0.1);
transition: background 0.2s, color 0.2s;
}
.gitea-link:hover {
background: rgba(16, 185, 129, 0.22);
color: #ecfdf5;
}
.gitea-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.repo-credit a {
color: #a7f3d0;
font-weight: 600;
@@ -81,6 +166,9 @@ h1:hover {
display: grid;
grid-template-columns: 460px 1fr;
gap: 28px;
flex: 1;
min-height: 0;
align-items: stretch;
transition: grid-template-columns 0.4s cubic-bezier(0.4, 0, 0.2, 1), gap 0.3s ease;
}
@@ -109,6 +197,8 @@ h1:hover {
border: 1px solid rgba(100, 149, 237, 0.1);
border-radius: 14px;
padding: 20px 20px 24px;
overflow-y: auto;
min-height: 0;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
@@ -318,6 +408,9 @@ h1:hover {
.preview-container {
padding: 28px;
display: flex;
flex-direction: column;
min-height: 0;
}
.section:hover {
@@ -681,12 +774,15 @@ input[type="checkbox"]:focus {
.previews-row {
display: flex;
gap: 12px;
align-items: flex-start;
flex: 1;
min-height: 0;
align-items: stretch;
}
.preview-face-wrap {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
@@ -709,13 +805,14 @@ input[type="checkbox"]:focus {
#preview,
#preview-bottom {
width: 100%;
height: 720px;
height: min(calc(100vh - 280px), 580px);
min-height: 380px;
border: 1px solid rgba(100, 149, 237, 0.2);
border-radius: 10px;
background: #1e293b;
display: block;
cursor: grab;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
#preview:hover,
@@ -1198,6 +1295,143 @@ input[type="checkbox"]:focus {
}
}
/* ── Copper Sheet Order Calculator ─────────────────────────────────────── */
.order-header {
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #64748b;
padding: 4px 0 10px;
margin-top: 4px;
}
.order-placeholder {
font-size: 0.85em;
color: #475569;
padding: 4px 0 8px;
}
.order-summary {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(100, 149, 237, 0.1);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 12px;
display: flex;
flex-direction: column;
gap: 5px;
}
.order-summary-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-size: 0.85em;
}
.order-label {
color: #64748b;
white-space: nowrap;
}
.order-value {
color: #e2e8f0;
font-weight: 600;
text-align: right;
}
.order-muted {
color: #64748b;
font-weight: 400;
font-size: 0.9em;
}
.order-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83em;
margin-bottom: 4px;
}
.order-table th {
text-align: left;
padding: 5px 8px;
color: #64748b;
font-weight: 600;
font-size: 0.9em;
border-bottom: 1px solid rgba(100, 149, 237, 0.15);
white-space: nowrap;
}
.order-table td {
padding: 5px 8px;
color: #cbd5e1;
border-bottom: 1px solid rgba(100, 149, 237, 0.06);
white-space: nowrap;
}
.order-table tr:last-child td {
border-bottom: none;
}
.order-row-nofit td {
color: #334155;
}
.order-row-best td {
color: #fff;
font-weight: 600;
}
.order-row-best td:last-child {
color: #6ee7b7;
}
.order-table tr:not(.order-row-nofit):not(.order-row-best):hover td {
background: rgba(100, 149, 237, 0.07);
color: #e2e8f0;
}
.order-warning {
display: flex;
align-items: flex-start;
gap: 7px;
font-size: 0.82em;
color: #fbbf24;
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.25);
border-radius: 6px;
padding: 7px 10px;
margin-bottom: 8px;
line-height: 1.4;
}
.order-warning strong {
color: #fde68a;
}
.order-busbar-sizes {
background: rgba(0, 0, 0, 0.15);
border: 1px solid rgba(100, 149, 237, 0.08);
border-radius: 6px;
padding: 7px 12px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.order-busbar-size-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-size: 0.82em;
}
/* Axis diagram — resolves width/depth confusion by showing which input maps to which
on-screen dimension. Mirrors the canvas orientation: X horizontal, Y vertical, Z depth. */
.axis-diagram {
@@ -1260,12 +1494,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 +1572,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 +1580,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;