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 # 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. 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 ## 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 charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="theme-color" content="#0a0f1e"> <meta name="theme-color" content="#0a0f1e">
<title>Cell Holder Generator</title> <title>Cell Holder Generator - waak.me</title>
<script type="module" crossorigin src="./assets/index-jBPW89B_.js"></script> <script type="module" crossorigin src="./assets/index-DwWFs8GA.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CXz5-edo.css"> <link rel="stylesheet" crossorigin href="./assets/index-DFysVyGJ.css">
</head> </head>
<body> <body>
<div class="loading-overlay active" id="loadingOverlay" style="display: flex !important;"> <div class="loading-overlay active" id="loadingOverlay" style="display: flex !important;">
@@ -18,7 +18,10 @@
</div> </div>
<div class="container"> <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> <p class="subtitle">Generate custom 3D printable cell holders with STEP export</p>
<div class="main-layout"> <div class="main-layout">
@@ -160,6 +163,15 @@
<input type="number" id="tabDepth" value="1.0" min="0.1" step="0.1"> <input type="number" id="tabDepth" value="1.0" min="0.1" step="0.1">
</div> </div>
</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>
<section class="tab-panel" role="tabpanel" data-panel="busbars"> <section class="tab-panel" role="tabpanel" data-panel="busbars">
@@ -171,8 +183,28 @@
</select> </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> <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>
<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> <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> </section>
</div> </div>
@@ -182,7 +214,16 @@
<div class="preview-container"> <div class="preview-container">
<h2>Preview</h2> <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 id="previewStats">Configure settings and click Generate to see preview</div>
</div> </div>
</div> </div>

View File

@@ -18,11 +18,28 @@
<div class="container"> <div class="container">
<div class="header-row"> <div class="header-row">
<h1>Cell Holder Generator</h1> <div class="header-title">
<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> <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> </div>
<p class="subtitle">Generate custom 3D printable cell holders with STEP export</p> <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="main-layout">
<div class="config-sidebar"> <div class="config-sidebar">
<nav class="sidebar-tabs" role="tablist" data-tabs> <nav class="sidebar-tabs" role="tablist" data-tabs>
@@ -154,14 +171,23 @@
</div> </div>
<div class="row" id="tabDimensionsGroup" style="display:none;"> <div class="row" id="tabDimensionsGroup" style="display:none;">
<div class="form-group"> <div class="form-group">
<label>Tab Width (mm)</label> <label>Tab Length (mm)</label>
<input type="number" id="tabWidth" value="4.0" min="0.5" step="0.1"> <input type="number" id="tabLength" value="10.0" min="1" step="0.5">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Tab Depth (mm)</label> <label>Edge Cut Width (mm)</label>
<input type="number" id="tabDepth" value="1.0" min="0.1" step="0.1"> <input type="number" id="tabWidth" value="4.0" min="1" step="0.5">
</div> </div>
</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>
<section class="tab-panel" role="tabpanel" data-panel="busbars"> <section class="tab-panel" role="tabpanel" data-panel="busbars">
@@ -173,6 +199,10 @@
</select> </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> <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>
<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-controls-row">
<div class="busbar-face-filter" role="group" aria-label="Face filter"> <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 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="addTopBusbarBtn">+ Top Busbar</button>
<button class="btn-secondary" id="addBottomBusbarBtn">+ Bottom Busbar</button> <button class="btn-secondary" id="addBottomBusbarBtn">+ Bottom Busbar</button>
</div> </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> </section>
</div> </div>

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

@@ -29,9 +29,20 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [ "license": "MIT",
"glibc" "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", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [

View File

@@ -1,13 +1,13 @@
{ {
"hash": "768e60fe", "hash": "8727c2cc",
"configHash": "cddbe005", "configHash": "cddbe005",
"lockfileHash": "6cdf80c6", "lockfileHash": "6e69140d",
"browserHash": "40f95f93", "browserHash": "80e0ce3b",
"optimized": { "optimized": {
"jszip": { "jszip": {
"src": "../../jszip/dist/jszip.min.js", "src": "../../jszip/dist/jszip.min.js",
"file": "jszip.js", "file": "jszip.js",
"fileHash": "26c0effe", "fileHash": "d6368be4",
"needsInterop": true "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" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -514,9 +511,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -531,9 +525,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -548,9 +539,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -565,9 +553,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -582,9 +567,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -599,9 +581,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -616,9 +595,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -633,9 +609,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -650,9 +623,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -667,9 +637,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -684,9 +651,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -701,9 +665,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [

View File

@@ -23,6 +23,289 @@ export function getLastBusbarGeometries() {
return lastComputedGeometries; 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) { function drawBothCanvases(positions, cellSize, padRadius, spacing) {
drawPreview(positions, cellSize); drawPreview(positions, cellSize);
@@ -202,9 +485,37 @@ export function updatePreview(resetView = false) {
const cellRadius = cellSize / 2; const cellRadius = cellSize / 2;
const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0); const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0);
const busbarKeepoutRadius = 4.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 => 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 }; lastBusbarDrawArgs = { positions, cellSize, padRadius: busbarPadRadius, spacing };
drawBothCanvases(positions, cellSize, busbarPadRadius, spacing); drawBothCanvases(positions, cellSize, busbarPadRadius, spacing);
@@ -224,7 +535,11 @@ export function updatePreview(resetView = false) {
const actualHeight = maxY - minY + cellSize + spacing * 2; const actualHeight = maxY - minY + cellSize + spacing * 2;
if (positions.length >= 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) { } catch (error) {
console.error('Preview error:', error); console.error('Preview error:', error);
@@ -448,14 +763,15 @@ export async function generateLayout() {
} }
} }
const tabWidth = parseFloat(document.getElementById('tabWidth').value) || 4.0; const edgeCutWidth = parseFloat(document.getElementById('tabWidth')?.value) || 4.0;
const tabDepth = parseFloat(document.getElementById('tabDepth').value) || 1.0; const tabLength = parseFloat(document.getElementById('tabLength')?.value) || 10.0;
const tabOverlapSide = document.getElementById('tabOverlapSide')?.value || 'off';
const config = { const config = {
cellSize, spacing, height, terminalDiameter, terminalDepth, cellSize, spacing, height, terminalDiameter, terminalDepth,
coverThickness, roundedCorners, bmsHoles, ledgeWidth, coverThickness, roundedCorners, bmsHoles, ledgeWidth,
filletBms, circleHoleOffset, useTabs, useFullCircles, bmsHoleDiameter, filletBms, circleHoleOffset, useTabs, useFullCircles, bmsHoleDiameter,
tabWidth, tabDepth, tabWidth: edgeCutWidth, tabLength, tabDepth: 1.0, tabOverlapSide, layoutType,
}; };
const holderShape = create3DModel(positions, config); const holderShape = create3DModel(positions, config);
@@ -467,9 +783,37 @@ export async function generateLayout() {
const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0); const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0);
const busbarKeepoutRadius = terminalDiameter / 2; 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 => 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++) { for (let i = 0; i < busbarStore.list.length; i++) {
const bb = busbarStore.list[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 // signatures are congruent, so we only need to print one copy. Uses sorted
// pairwise cell distances; pairwise distance sets are the same under // pairwise cell distances; pairwise distance sets are the same under
// translation, rotation, and mirror. // translation, rotation, and mirror.
const busbarSignature = (bb) => { const busbarSignature = (bb, geom) => {
const idxs = bb.cellIndices; 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 === 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 pts = idxs.map(i => centeredPositions[i]).filter(Boolean);
const dists = []; const dists = [];
for (let a = 0; a < pts.length; a++) { for (let a = 0; a < pts.length; a++) {
@@ -504,7 +856,7 @@ export async function generateLayout() {
} }
} }
dists.sort((x, y) => x - y); 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 // 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++) { for (let i = 0; i < busbarStore.list.length; i++) {
const bb = busbarStore.list[i]; const bb = busbarStore.list[i];
if (bb.cellIndices.length === 0) continue; if (bb.cellIndices.length === 0) continue;
const sig = busbarSignature(bb); const sig = busbarSignature(bb, busbarGeometries[i]);
if (sig && sigSeen.has(sig)) { if (sig && sigSeen.has(sig)) {
sigSeen.get(sig).copies.push(bb.name); sigSeen.get(sig).copies.push(bb.name);
continue; continue;
} }
const entry = { bb, geom: busbarGeometries[i], copies: [bb.name], shape: null }; const entry = { bb, geom: busbarGeometries[i], copies: [bb.name], shape: null };
if (busbarFormat === 'step') { 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; if (!entry.shape) continue;
} }
uniqueBusbars.push(entry); uniqueBusbars.push(entry);
@@ -540,7 +892,7 @@ export async function generateLayout() {
const { bb, geom, shape } = uniqueBusbars[i]; const { bb, geom, shape } = uniqueBusbars[i];
const base = `busbar_${safeName(bb.name)}`; const base = `busbar_${safeName(bb.name)}`;
if (busbarFormat === 'dxf') { if (busbarFormat === 'dxf') {
const content = buildBusbarDXF(geom, centeredPositions, busbarPadRadius); const content = buildBusbarDXF(centeredGeom(geom, holderCenterX, holderCenterY), centeredPositions, busbarPadRadius);
downloadDXF(content, `${base}.dxf`); downloadDXF(content, `${base}.dxf`);
} else { } else {
downloadSTEP(shape, `${base}.step`); downloadSTEP(shape, `${base}.step`);
@@ -570,6 +922,21 @@ export async function generateLayout() {
// ── Per-busbar download helpers ──────────────────────────────────────────────── // ── 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() { function getBusbarExportContext() {
if (!lastBusbarDrawArgs || lastComputedGeometries.length === 0) return null; if (!lastBusbarDrawArgs || lastComputedGeometries.length === 0) return null;
const { positions, padRadius } = lastBusbarDrawArgs; const { positions, padRadius } = lastBusbarDrawArgs;
@@ -579,7 +946,7 @@ function getBusbarExportContext() {
const height = parseFloat(document.getElementById('height').value); const height = parseFloat(document.getElementById('height').value);
const busbarFormat = document.getElementById('busbarFormat')?.value || 'step'; const busbarFormat = document.getElementById('busbarFormat')?.value || 'step';
const safeName = (name) => (name || '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'busbar'; 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) { export async function downloadSingleBusbar(busbarId) {
@@ -609,10 +976,10 @@ export async function downloadSingleBusbar(busbarId) {
await new Promise(r => setTimeout(r, 20)); await new Promise(r => setTimeout(r, 20));
try { try {
if (ctx.busbarFormat === 'dxf') { 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`); downloadDXF(content, `${base}.dxf`);
} else { } 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; } if (!shape) { showStatus(`Failed to build 3D shape for ${bb.name}.`, 'error'); return; }
downloadSTEP(shape, `${base}.step`); downloadSTEP(shape, `${base}.step`);
} }
@@ -648,10 +1015,10 @@ export async function downloadAllBusbarsZip() {
for (const { bb, geom } of eligible) { for (const { bb, geom } of eligible) {
const base = `busbar_${ctx.safeName(bb.name)}`; const base = `busbar_${ctx.safeName(bb.name)}`;
if (ctx.busbarFormat === 'dxf') { 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); zip.file(`${base}.dxf`, content);
} else { } 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; if (!shape) continue;
const bytes = buildSTEPBytes(shape, `_zip_${base}.step`); const bytes = buildSTEPBytes(shape, `_zip_${base}.step`);
if (bytes) zip.file(`${base}.step`, bytes); if (bytes) zip.file(`${base}.step`, bytes);

View File

@@ -155,12 +155,470 @@ function smoothPolylinePoints(points, padRadius) {
return out; 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) { if (cellIndices.length === 0) {
return { padIndices: [], edges: [], blocked: null }; return { padIndices: [], edges: [], blocked: null, extraPads: [], extraSegments: [], cutouts: [] };
} }
if (cellIndices.length === 1) { 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); const edgePairs = buildEdgePairs(cellIndices, positions, cellRadius, spacing);
@@ -185,6 +643,9 @@ export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRad
padIndices: cellIndices.slice(), padIndices: cellIndices.slice(),
edges, edges,
blocked: { from: i, to: j, reason: 'no clear route between these cells' }, 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; if (!oc || geometry.padIndices.length === 0) return null;
const shapes = []; 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) { for (const i of geometry.padIndices) {
if (!positions[i]) continue; 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()); 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) { for (const edge of geometry.edges) {
const pts = [positions[edge.from], ...edge.waypoints, positions[edge.to]]; 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; if (shapes.length === 0) return null;
let combined = shapes[0]; let combined = shapes[0];
for (let i = 1; i < shapes.length; i++) { for (let i = 1; i < shapes.length; i++) {
combined = new oc.BRepAlgoAPI_Fuse(combined, shapes[i]).Shape(); 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; return combined;
} }

View File

@@ -20,7 +20,10 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
const cellR = cellSize / 2; const cellR = cellSize / 2;
const toScreenX = (wx) => (wx - t.minX + cellR + spacing) * t.scale + t.offsetX; 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 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.save();
ctx.translate(canvasState.panX, canvasState.panY); 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 // 1. Capsule strokes between direct neighbours (lineCap:round = semicircle
// at each end, so two meeting capsules form a full circle at every junction). // at each end, so two meeting capsules form a full circle at every junction).
off.strokeStyle = opaqueColor; off.strokeStyle = opaqueColor;
off.lineWidth = 2 * padRadiusScreen; off.lineWidth = 2 * padRadiusScreen();
off.lineCap = 'round'; off.lineCap = 'round';
off.lineJoin = 'round'; off.lineJoin = 'round';
for (const [a, b] of adjCaps) { 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.lineTo(toScreenX(pb[0]), toScreenY(pb[1]));
off.stroke(); 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. // 2. Circle fills at every cell pad.
off.fillStyle = opaqueColor; off.fillStyle = opaqueColor;
off.beginPath(); off.beginPath();
for (const ci of cellIndices) { for (const ci of cellIndices) {
const p = positions[ci]; if (!p) continue; const p = positions[ci]; if (!p) continue;
const radius = padRadiusScreen();
const sx = toScreenX(p[0]), sy = toScreenY(p[1]); const sx = toScreenX(p[0]), sy = toScreenY(p[1]);
off.moveTo(sx + padRadiusScreen, sy); off.moveTo(sx + radius, sy);
off.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2); 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(); off.fill();
// 3. Triangle fills for every mutually-adjacent triplet. // 3. Fill the three-circle interstice without extending to the cell centers.
// The centre of three packed circles (the "interstice") lies outside all // Use curved joins through the centroid so boundary bays stay rounded
// three circles AND all three capsules, so without this step those points // instead of forming straight-sided inward peaks.
// remain unfilled and appear as sharp V-notches.
off.fillStyle = opaqueColor; off.fillStyle = opaqueColor;
for (let a = 0; a < nCells; a++) { for (let a = 0; a < nCells; a++) {
for (const b of triAdj[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 pb = positions[cellIndices[b]];
const pc = positions[cellIndices[c]]; const pc = positions[cellIndices[c]];
if (!pa || !pb || !pc) continue; 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.beginPath();
off.moveTo(toScreenX(pa[0]), toScreenY(pa[1])); off.moveTo(toScreenX(tangentPoints[0][0]), toScreenY(tangentPoints[0][1]));
off.lineTo(toScreenX(pb[0]), toScreenY(pb[1])); off.quadraticCurveTo(
off.lineTo(toScreenX(pc[0]), toScreenY(pc[1])); 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.closePath();
off.fill(); 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). // 4. Obstacle-avoidance detour waypoints (spanning-tree edges with bends).
off.strokeStyle = opaqueColor; off.strokeStyle = opaqueColor;
off.lineWidth = 2 * padRadiusScreen; off.lineWidth = 2 * padRadiusScreen();
off.lineCap = 'round'; off.lineCap = 'round';
for (const edge of geom.edges) { for (const edge of geom.edges) {
if (edge.waypoints.length === 0) continue; if (edge.waypoints.length === 0) continue;
@@ -142,6 +240,17 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
off.stroke(); 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) // Composite offscreen onto main canvas at fillAlpha (reset transform for pixel-exact drawImage)
ctx.save(); ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); 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 [x, y] = positions[i];
const sx = toScreenX(x), sy = toScreenY(y); const sx = toScreenX(x), sy = toScreenY(y);
ctx.beginPath(); ctx.beginPath();
ctx.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2); ctx.arc(sx, sy, padRadiusScreen(), 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
} }
} }

View File

@@ -31,13 +31,22 @@ function buildBusbarRow(bb, blockedByBusbarId) {
row.innerHTML = ` row.innerHTML = `
<div class="busbar-header"> <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)}"> <input class="busbar-name" type="text" value="${escapeHtml(bb.name)}">
<button class="busbar-dl" title="Download this busbar">${DL_ICON}</button> <button class="busbar-dl" title="Download this busbar">${DL_ICON}</button>
<button class="busbar-del" title="Delete">×</button> <button class="busbar-del" title="Delete">×</button>
</div> </div>
<div class="busbar-meta"> <div class="busbar-meta">
<span class="busbar-count">${bb.cellIndices.length} cell${bb.cellIndices.length === 1 ? '' : 's'}</span> <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 <label class="busbar-thickness-label">Thickness
<input class="busbar-thickness" type="number" value="${bb.thickness}" step="0.1" min="0.1"> <input class="busbar-thickness" type="number" value="${bb.thickness}" step="0.1" min="0.1">
</label> </label>
@@ -52,6 +61,16 @@ function buildBusbarRow(bb, blockedByBusbarId) {
row.querySelector('.busbar-name').addEventListener('change', (e) => { row.querySelector('.busbar-name').addEventListener('change', (e) => {
busbarStore.rename(bb.id, e.target.value); 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) => { row.querySelector('.busbar-thickness').addEventListener('change', (e) => {
const v = parseFloat(e.target.value); const v = parseFloat(e.target.value);
if (v > 0) busbarStore.setThickness(bb.id, v); 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); b.classList.toggle('active', b.dataset.filter === faceFilter);
}); });
if (bottomFaceWrap) bottomFaceWrap.hidden = (faceFilter === 'top'); if (bottomFaceWrap) bottomFaceWrap.hidden = (faceFilter === 'top');
if (onFaceFilterChange) onFaceFilterChange(); if (onFaceFilterChange) onFaceFilterChange(faceFilter);
renderBusbarList(); renderBusbarList();
}); });
}); });

View File

@@ -39,6 +39,10 @@ export const busbarStore = {
color: b.color, color: b.color,
cellIndices: Array.isArray(b.cellIndices) ? [...b.cellIndices] : [], cellIndices: Array.isArray(b.cellIndices) ? [...b.cellIndices] : [],
thickness: b.thickness, 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', face: b.face === 'bottom' ? 'bottom' : 'top',
})), })),
}; };
@@ -58,6 +62,10 @@ export const busbarStore = {
thickness: Number.isFinite(Number(b.thickness)) && Number(b.thickness) > 0 thickness: Number.isFinite(Number(b.thickness)) && Number(b.thickness) > 0
? Number(b.thickness) ? Number(b.thickness)
: 1.0, : 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', face: b.face === 'bottom' ? 'bottom' : 'top',
})); }));
@@ -92,6 +100,8 @@ export const busbarStore = {
color, color,
cellIndices: [], cellIndices: [],
thickness: 1.0, thickness: 1.0,
overlapEnabled: false,
overlapSize: 10,
face: face === 'bottom' ? 'bottom' : 'top', face: face === 'bottom' ? 'bottom' : 'top',
}; };
paletteIdx++; 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) { setFace(id, face) {
const b = this.list.find(b => b.id === id); const b = this.list.find(b => b.id === id);
if (b) { if (b) {

View File

@@ -148,21 +148,30 @@ function angleCovered(theta, dirs) {
export function buildBusbarDXF(geometry, positions, padRadius) { export function buildBusbarDXF(geometry, positions, padRadius) {
const layer = 'busbar'; const layer = 'busbar';
const tokens = [...dxfHeader(), ...dxfTables(), '0', 'SECTION', '2', 'ENTITIES']; 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 // Enumerate pads (cells + waypoints) and capsule segments with shared keys so we
// can tell which shapes are "self" vs "other" when clipping. // can tell which shapes are "self" vs "other" when clipping.
const pads = new Map(); const pads = new Map();
const caps = []; // { a, b, padKeyA, padKeyB } const caps = []; // { a, b, padKeyA, padKeyB, radius }
const ensurePad = (key, pos) => { const ensurePad = (key, pos, radius = padRadius) => {
if (!pads.has(key)) pads.set(key, { pos, dirs: [] }); 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); return pads.get(key);
}; };
for (const idx of geometry.padIndices) { for (const idx of geometry.padIndices) {
const p = positions[idx]; const p = positions[idx];
if (!p) continue; 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) => { 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 })), ...edge.waypoints.map((wp, wi) => ({ key: `w${ei}_${wi}`, pos: wp })),
{ key: `c${edge.to}`, pos: to }, { 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++) { for (let i = 0; i < stops.length - 1; i++) {
const a = stops[i], b = stops[i + 1]; const a = stops[i], b = stops[i + 1];
const dx = b.pos[0] - a.pos[0]; const dx = b.pos[0] - a.pos[0];
@@ -182,19 +191,39 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
const len = Math.hypot(dx, dy); const len = Math.hypot(dx, dy);
if (len < EPS) continue; if (len < EPS) continue;
const ang = Math.atan2(dy, dx); const ang = Math.atan2(dy, dx);
ensurePad(a.key, a.pos).dirs.push(ang); ensurePad(a.key, a.pos, padRadius).dirs.push(ang);
ensurePad(b.key, b.pos).dirs.push(ang + Math.PI); 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 }); 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 // 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. // AND not interior to any other capsule rectangle or pad circle.
const padList = Array.from(pads.entries()).map(([key, data]) => ({ key, ...data })); const padList = Array.from(pads.entries()).map(([key, data]) => ({ key, ...data }));
for (const pad of padList) { for (const pad of padList) {
const [cx, cy] = pad.pos; const [cx, cy] = pad.pos;
const localRadius = pad.radius ?? padRadius;
if (pad.dirs.length === 0) { if (pad.dirs.length === 0) {
tokens.push(...circleEntity(cx, cy, padRadius, layer)); tokens.push(...circleEntity(cx, cy, localRadius, layer));
continue; continue;
} }
const tangents = []; 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 // 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 // genuinely on the outline — an arc that enters a neighbour's capsule is
// interior and must be dropped. // interior and must be dropped.
const mx = cx + padRadius * Math.cos(mid); const mx = cx + localRadius * Math.cos(mid);
const my = cy + padRadius * Math.sin(mid); const my = cy + localRadius * Math.sin(mid);
let buried = false; let buried = false;
for (const cap of caps) { for (const cap of caps) {
if (cap.padKeyA === pad.key || cap.padKeyB === pad.key) continue; 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) { if (!buried) {
for (const other of padList) { for (const other of padList) {
if (other.key === pad.key) continue; if (other.key === pad.key) continue;
const dx = mx - other.pos[0], dy = my - other.pos[1]; 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 dy = cap.b[1] - cap.a[1];
const len = Math.hypot(dx, dy); const len = Math.hypot(dx, dy);
if (len < EPS) continue; if (len < EPS) continue;
const nx = -dy / len * padRadius; const localRadius = cap.radius ?? padRadius;
const ny = dx / len * padRadius; const nx = -dy / len * localRadius;
const ny = dx / len * localRadius;
const sides = [ 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] },
{ 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 = []; const insides = [];
for (let j = 0; j < caps.length; j++) { for (let j = 0; j < caps.length; j++) {
if (j === ci) continue; 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) { for (const pad of padList) {
if (pad.key === cap.padKeyA || pad.key === cap.padKeyB) continue; 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); const outside = subtractIntervals(insides);
for (const [t0, t1] of outside) { 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'); tokens.push('0', 'ENDSEC', '0', 'EOF');
return tokens.join('\n') + '\n'; return tokens.join('\n') + '\n';
} }

View File

@@ -2,10 +2,11 @@ import { canvasState } from './state.js';
import { initOC } from './oc.js'; import { initOC } from './oc.js';
import { toggleBmsDiameter, initCustomSelects, showStatus } from './ui.js'; import { toggleBmsDiameter, initCustomSelects, showStatus } from './ui.js';
import { drawPreview } from './preview.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 { busbarStore } from './busbars.js';
import { initBusbarUI, renderBusbarList } from './busbar-ui.js'; import { initBusbarUI, renderBusbarList } from './busbar-ui.js';
import { captureConfig, encodeConfigToHash, decodeHashToConfig } from './url-config.js'; import { captureConfig, encodeConfigToHash, decodeHashToConfig } from './url-config.js';
import { renderOrderSection } from './order.js';
const CLICK_PIXEL_THRESHOLD = 4; const CLICK_PIXEL_THRESHOLD = 4;
const URL_SYNC_DEBOUNCE_MS = 250; const URL_SYNC_DEBOUNCE_MS = 250;
@@ -18,14 +19,13 @@ function scaleCanvasById(id) {
const canvas = document.getElementById(id); const canvas = document.getElementById(id);
if (!canvas) return; if (!canvas) return;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
// Only update the drawing buffer to match the current CSS-rendered size × DPR.
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return; if (rect.width === 0 || rect.height === 0) return;
canvas.width = rect.width * dpr; canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr; canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
} }
function scaleCanvasForDPI() { function scaleCanvasForDPI() {
@@ -144,9 +144,11 @@ function applyConfigToUi(config) {
setSelectInput('bmsHolesType', config.bms.type); setSelectInput('bmsHolesType', config.bms.type);
setNumberInput('bmsHoleDiameter', config.bms.holeDiameter); setNumberInput('bmsHoleDiameter', config.bms.holeDiameter);
setNumberInput('tabWidth', config.bms.tabWidth); 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); setSelectInput('busbarFormat', config.busbars.format);
setCheckboxInput('busbarCellCutoutEnabled', config.busbars.cellCutoutEnabled === true);
setPackMode(config.pack.mode, { clearBusbars: false, refresh: false }); setPackMode(config.pack.mode, { clearBusbars: false, refresh: false });
toggleBmsDiameter(); toggleBmsDiameter();
@@ -222,8 +224,8 @@ function wireUrlSyncListeners() {
const ids = [ const ids = [
'series', 'parallel', 'xDim', 'yDim', 'height', 'series', 'parallel', 'xDim', 'yDim', 'height',
'cellSize', 'layoutType', 'spacing', 'coverThickness', 'ledgeWidth', 'cellSize', 'layoutType', 'spacing', 'coverThickness', 'ledgeWidth',
'roundedCorners', 'bmsHolesType', 'bmsHoleDiameter', 'tabWidth', 'tabDepth', 'roundedCorners', 'bmsHolesType', 'bmsHoleDiameter', 'tabWidth',
'busbarFormat', 'tabOverlapSide', 'busbarFormat', 'busbarCellCutoutEnabled',
]; ];
ids.forEach((id) => { ids.forEach((id) => {
@@ -338,7 +340,7 @@ function wireInputs() {
element.addEventListener('change', handler); 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 => { visualInputs.forEach(id => {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) return; if (!element) return;
@@ -625,6 +627,8 @@ function wireSidebarTabs() {
async function initializeApp() { async function initializeApp() {
scaleCanvasForDPI(); scaleCanvasForDPI();
initCustomSelects(); initCustomSelects();
setOrderUpdateCallback(renderOrderSection);
busbarStore.subscribe(() => refreshOrderFromLastState());
wireSidebarTabs(); wireSidebarTabs();
wirePackMode(); wirePackMode();
@@ -642,11 +646,14 @@ async function initializeApp() {
initBusbarUI({ initBusbarUI({
onDownloadSingle: downloadSingleBusbar, onDownloadSingle: downloadSingleBusbar,
onDownloadAll: downloadAllBusbarsZip, onDownloadAll: downloadAllBusbarsZip,
onFaceFilterChange() { onFaceFilterChange(filter) {
requestAnimationFrame(() => { // Double rAF: first frame applies the hidden/visible DOM change,
// second frame reads the settled flex layout dimensions.
requestAnimationFrame(() => requestAnimationFrame(() => {
scaleCanvasById('preview'); scaleCanvasById('preview');
if (filter === 'both') scaleCanvasById('preview-bottom');
redrawBusbarOverlay(); redrawBusbarOverlay();
}); }));
}, },
}); });
renderBusbarList(); renderBusbarList();
@@ -664,6 +671,22 @@ async function initializeApp() {
await initOC(); 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(() => { setTimeout(() => {
// Force a fresh render after both OC init and config load are complete. // Force a fresh render after both OC init and config load are complete.
// This ensures viewTransform and geometries are in sync with loaded busbars. // This ensures viewTransform and geometries are in sync with loaded busbars.

View File

@@ -17,6 +17,8 @@ export function create3DModel(positions, config) {
useTabs, useTabs,
useFullCircles, useFullCircles,
filletBms, filletBms,
tabOverlapSide,
layoutType = 'honeycomb',
} = config; } = config;
const r = cellSize / 2; 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 topRow = rows[topYKey].sort((a, b) => a[0] - b[0]);
const bottomRow = rows[bottomYKey].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 = []; const topHoles = [];
for (let i = 0; i < topRow.length - 1; i++) { for (let i = 0; i < topRow.length - 1; i++) {
const xMid = (topRow[i][0] + topRow[i + 1][0]) / 2; topHoles.push([(topRow[i][0] + topRow[i + 1][0]) / 2, holeYTop]);
topHoles.push([xMid, 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 = []; const bottomHoles = [];
for (let i = 0; i < bottomRow.length - 1; i++) { for (let i = 0; i < bottomRow.length - 1; i++) {
const xMid = (bottomRow[i][0] + bottomRow[i + 1][0]) / 2; bottomHoles.push([(bottomRow[i][0] + bottomRow[i + 1][0]) / 2, holeYBottom]);
bottomHoles.push([xMid, 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]; const allBmsHoles = [...topHoles, ...bottomHoles];
@@ -229,25 +266,28 @@ export function create3DModel(positions, config) {
if (useTabs) { if (useTabs) {
const slotWidth = config.tabWidth || holeDiameter; const slotWidth = config.tabWidth || holeDiameter;
const slotInset = config.tabDepth || 1.0; 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 topEdgeY = length / 2;
const bottomEdgeY = -length / 2; const bottomEdgeY = -length / 2;
const allSlots = []; const allSlots = [];
topHoles.forEach(([xPos]) => { 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 slot = slotBox.Shape();
const trans = new oc.gp_Trsf(); 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); const slotTransform = new oc.BRepBuilderAPI_Transform(slot, trans, false);
allSlots.push(slotTransform.Shape()); allSlots.push(slotTransform.Shape());
}); });
bottomHoles.forEach(([xPos]) => { 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 slot = slotBox.Shape();
const trans = new oc.gp_Trsf(); 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); const slotTransform = new oc.BRepBuilderAPI_Transform(slot, trans, false);
allSlots.push(slotTransform.Shape()); 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 useTabs = bmsHolesType === 'tabs';
const useFullCircles = bmsHolesType === 'fullcircles'; const useFullCircles = bmsHolesType === 'fullcircles';
const circleHoleOffset = false; const circleHoleOffset = false;
const layoutType = document.getElementById('layoutType').value;
const roundedCorners = document.getElementById('roundedCorners').checked; const roundedCorners = document.getElementById('roundedCorners').checked;
const bmsHoleDiameter = parseFloat(document.getElementById('bmsHoleDiameter').value) || 4.0; const bmsHoleDiameter = parseFloat(document.getElementById('bmsHoleDiameter').value) || 4.0;
const ledgeWidth = parseFloat(document.getElementById('ledgeWidth').value) || 0; const ledgeWidth = parseFloat(document.getElementById('ledgeWidth').value) || 0;
@@ -238,11 +239,18 @@ export function drawPreview(positions, cellSize) {
const holeTopRowKey = useFullCircles ? visualTopRowKey : topYKey; const holeTopRowKey = useFullCircles ? visualTopRowKey : topYKey;
const holeBottomRowKey = useFullCircles ? visualBottomRowKey : bottomYKey; const holeBottomRowKey = useFullCircles ? visualBottomRowKey : bottomYKey;
for (let i = 0; i < rows[holeTopRowKey].length - 1; i++) { // Vertical column pitch: minimum X delta between any two cells
const x = (rows[holeTopRowKey][i][0] + rows[holeTopRowKey][i + 1][0]) / 2; const _allXSorted = [...new Set(positions.map(([x]) => Math.round(x * 1000)))]
const cellY = rows[holeTopRowKey][i][1]; .sort((a, b) => a - b).map(v => v / 1000);
const x1 = rows[holeTopRowKey][i][0]; const vertColPitch = _allXSorted.length >= 2 ? _allXSorted[1] - _allXSorted[0] : 0;
const x2 = rows[holeTopRowKey][i + 1][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 wallY = packMinY;
const y = topEdge; const y = topEdge;
const flip = cellY < wallY ? -1 : 1; 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; const debugTri = useFullCircles ? { apex: { x, y: wallY }, left, right } : null;
bmsHolePositions.push({ x, y, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri }); 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 wallY = packMaxY;
const y = bottomEdge; const y = bottomEdge;
const flip = cellY < wallY ? -1 : 1; 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; const debugTri = useFullCircles ? { apex: { x, y: wallY }, left, right } : null;
bmsHolePositions.push({ x, y, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri }); 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'; ctx.fillStyle = '#1e293b';
@@ -321,20 +384,19 @@ export function drawPreview(positions, cellSize) {
ctx.strokeStyle = 'rgba(255, 193, 7, 0.9)'; ctx.strokeStyle = 'rgba(255, 193, 7, 0.9)';
ctx.lineWidth = 1.5 / zoom; ctx.lineWidth = 1.5 / zoom;
const tabWidthMm = parseFloat(document.getElementById('tabWidth').value) || 4.0; const tabWidthMm = parseFloat(document.getElementById('tabWidth')?.value) || 4.0;
const tabDepthMm = parseFloat(document.getElementById('tabDepth').value) || 1.0;
const tabWidth = tabWidthMm * scale; const tabWidth = tabWidthMm * scale;
const tabHeight = tabDepthMm * scale; const tabHeight = 1.0 * scale;
for (const hole of bmsHolePositions) { for (const hole of bmsHolePositions) {
const cx = (hole.x - minX + r + spacing) * scale + offsetX; const cx = (hole.x - minX + r + spacing) * scale + offsetX;
if (hole.y > maxY) { if (hole.y < minY) {
ctx.fillRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
ctx.strokeRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
} else {
ctx.fillRect(cx - tabWidth / 2, offsetY, tabWidth, tabHeight); ctx.fillRect(cx - tabWidth / 2, offsetY, tabWidth, tabHeight);
ctx.strokeRect(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) { } else if (useFullCircles) {

View File

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

View File

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

View File

@@ -10,26 +10,29 @@ body {
min-height: 100vh; min-height: 100vh;
color: #e2e8f0; color: #e2e8f0;
line-height: 1.6; line-height: 1.6;
padding: 48px 24px; padding: 12px 24px;
} }
.container { .container {
max-width: 1600px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
transition: max-width 0.3s ease, padding 0.3s ease; transition: max-width 0.3s ease, padding 0.3s ease;
} }
/* Header */ /* Header */
.header-row { .header-row {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 12px;
margin-bottom: 10px; margin-bottom: 4px;
} }
h1 { h1 {
font-size: 2.5em; font-size: 1.6em;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
margin-bottom: 0; margin-bottom: 0;
@@ -45,26 +48,108 @@ h1:hover {
.subtitle { .subtitle {
color: #94a3b8; color: #94a3b8;
font-size: 1em; font-size: 0.88em;
margin-bottom: 32px; margin-bottom: 6px;
transition: color 0.3s ease; transition: color 0.3s ease;
animation: fadeInUp 0.6s ease-out 0.1s both; animation: fadeInUp 0.6s ease-out 0.1s both;
} }
.repo-credit { .repo-credit {
margin: 0; margin: 0;
padding: 10px 14px; padding: 5px 10px;
color: #dbeafe; color: #a7f3d0;
font-size: 0.95em; font-size: 0.85em;
font-weight: 600;
line-height: 1.5; line-height: 1.5;
text-align: center; text-align: center;
max-width: 560px; background: rgba(16, 185, 129, 0.1);
background: linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(59, 130, 246, 0.12));
border: 1px solid rgba(110, 231, 183, 0.35); border: 1px solid rgba(110, 231, 183, 0.35);
border-radius: 10px; border-radius: 8px;
animation: fadeInUp 0.6s ease-out 0.15s both; 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 { .repo-credit a {
color: #a7f3d0; color: #a7f3d0;
font-weight: 600; font-weight: 600;
@@ -81,6 +166,9 @@ h1:hover {
display: grid; display: grid;
grid-template-columns: 460px 1fr; grid-template-columns: 460px 1fr;
gap: 28px; 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; 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: 1px solid rgba(100, 149, 237, 0.1);
border-radius: 14px; border-radius: 14px;
padding: 20px 20px 24px; padding: 20px 20px 24px;
overflow-y: auto;
min-height: 0;
transition: border-color 0.3s ease, box-shadow 0.3s ease; transition: border-color 0.3s ease, box-shadow 0.3s ease;
} }
@@ -318,6 +408,9 @@ h1:hover {
.preview-container { .preview-container {
padding: 28px; padding: 28px;
display: flex;
flex-direction: column;
min-height: 0;
} }
.section:hover { .section:hover {
@@ -681,12 +774,15 @@ input[type="checkbox"]:focus {
.previews-row { .previews-row {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: flex-start; flex: 1;
min-height: 0;
align-items: stretch;
} }
.preview-face-wrap { .preview-face-wrap {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
@@ -709,13 +805,14 @@ input[type="checkbox"]:focus {
#preview, #preview,
#preview-bottom { #preview-bottom {
width: 100%; width: 100%;
height: 720px; height: min(calc(100vh - 280px), 580px);
min-height: 380px;
border: 1px solid rgba(100, 149, 237, 0.2); border: 1px solid rgba(100, 149, 237, 0.2);
border-radius: 10px; border-radius: 10px;
background: #1e293b; background: #1e293b;
display: block; display: block;
cursor: grab; 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, #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 /* 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. */ on-screen dimension. Mirrors the canvas orientation: X horizontal, Y vertical, Z depth. */
.axis-diagram { .axis-diagram {
@@ -1260,12 +1494,40 @@ input[type="checkbox"]:focus {
gap: 8px; gap: 8px;
} }
.busbar-swatch { .busbar-color-wrap {
width: 16px; width: 20px;
height: 16px; height: 20px;
border-radius: 4px; display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0; flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15); 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 { .busbar-name {
@@ -1310,6 +1572,7 @@ input[type="checkbox"]:focus {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 6px; margin-top: 6px;
padding-left: 24px; padding-left: 24px;
@@ -1317,6 +1580,47 @@ input[type="checkbox"]:focus {
color: #94a3b8; 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 { .busbar-thickness-label {
display: flex; display: flex;
align-items: center; align-items: center;