added edge tabs and a lot of bug fixes abizt overlaps
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
1
dist/assets/index-CXz5-edo.css
vendored
1
dist/assets/index-CXz5-edo.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-DFysVyGJ.css
vendored
Normal file
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
26
dist/assets/index-DwWFs8GA.js
vendored
Normal file
File diff suppressed because one or more lines are too long
16
dist/assets/index-jBPW89B_.js
vendored
16
dist/assets/index-jBPW89B_.js
vendored
File diff suppressed because one or more lines are too long
12
dist/assets/jszip.min-BiHF8TMC.js
vendored
Normal file
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
53
dist/index.html
vendored
@@ -4,9 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
|
||||
<meta name="theme-color" content="#0a0f1e">
|
||||
<title>Cell Holder Generator</title>
|
||||
<script type="module" crossorigin src="./assets/index-jBPW89B_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CXz5-edo.css">
|
||||
<title>Cell Holder Generator - waak.me</title>
|
||||
<script type="module" crossorigin src="./assets/index-DwWFs8GA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DFysVyGJ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="loading-overlay active" id="loadingOverlay" style="display: flex !important;">
|
||||
@@ -18,7 +18,10 @@
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h1>Cell Holder Generator</h1>
|
||||
<div class="header-row">
|
||||
<h1>Cell Holder Generator</h1>
|
||||
<p class="repo-credit">Forked from <a href="https://github.com/waak86/battery-builder" target="_blank" rel="noopener noreferrer">waak's Battery Builder</a> <br>Huge thanks to <a href="https://t.me/waak86" target="_blank" rel="noopener noreferrer">waak</a> for the original project. </p>
|
||||
</div>
|
||||
<p class="subtitle">Generate custom 3D printable cell holders with STEP export</p>
|
||||
|
||||
<div class="main-layout">
|
||||
@@ -160,6 +163,15 @@
|
||||
<input type="number" id="tabDepth" value="1.0" min="0.1" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="tabOverlapSideGroup" style="display:none;">
|
||||
<label>Tab Overlap Side</label>
|
||||
<select id="tabOverlapSide">
|
||||
<option value="off" selected>Off</option>
|
||||
<option value="top">Top</option>
|
||||
<option value="bottom">Bottom</option>
|
||||
</select>
|
||||
<span class="field-hint">Adds one continuous outside lip across the selected edge tabs.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" role="tabpanel" data-panel="busbars">
|
||||
@@ -171,8 +183,28 @@
|
||||
</select>
|
||||
<span class="field-hint">STEP exports a 3D solid for CAD or 3D printing. DXF exports a flat 2D outline for laser or plasma cutters.</span>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="busbarCellCutoutEnabled">
|
||||
<label for="busbarCellCutoutEnabled">Cell center cutout (5x2 mm) for all busbars</label>
|
||||
</div>
|
||||
<div class="busbar-controls-row">
|
||||
<div class="busbar-face-filter" role="group" aria-label="Face filter">
|
||||
<button type="button" class="face-filter-btn active" data-filter="both">Top & Bottom</button>
|
||||
<button type="button" class="face-filter-btn" data-filter="top">Top only</button>
|
||||
</div>
|
||||
<div class="busbar-controls-right">
|
||||
<button type="button" class="btn-ghost btn-dl-all" id="downloadAllBusbarsBtn" title="Download all busbars as a ZIP file">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v13M7 11l5 5 5-5"/><path d="M4 20h16"/></svg>
|
||||
ZIP all
|
||||
</button>
|
||||
<button type="button" class="btn-ghost btn-clear-markings" id="clearMarkingsBtn" title="Remove all cell assignments without deleting busbars">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="busbarList"></div>
|
||||
<button class="btn-secondary" id="addBusbarBtn">+ Add Busbar</button>
|
||||
<div class="busbar-add-row">
|
||||
<button class="btn-secondary" id="addTopBusbarBtn">+ Top Busbar</button>
|
||||
<button class="btn-secondary" id="addBottomBusbarBtn">+ Bottom Busbar</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -182,7 +214,16 @@
|
||||
|
||||
<div class="preview-container">
|
||||
<h2>Preview</h2>
|
||||
<canvas id="preview"></canvas>
|
||||
<div class="previews-row">
|
||||
<div class="preview-face-wrap">
|
||||
<div class="face-label" id="topFaceLabel">Top</div>
|
||||
<canvas id="preview"></canvas>
|
||||
</div>
|
||||
<div class="preview-face-wrap" id="bottomFaceWrap">
|
||||
<div class="face-label" id="bottomFaceLabel">Bottom</div>
|
||||
<canvas id="preview-bottom"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div id="previewStats">Configure settings and click Generate to see preview</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
21
index.html
21
index.html
@@ -154,14 +154,19 @@
|
||||
</div>
|
||||
<div class="row" id="tabDimensionsGroup" style="display:none;">
|
||||
<div class="form-group">
|
||||
<label>Tab Width (mm)</label>
|
||||
<input type="number" id="tabWidth" value="4.0" min="0.5" step="0.1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tab Depth (mm)</label>
|
||||
<input type="number" id="tabDepth" value="1.0" min="0.1" step="0.1">
|
||||
<label>Tab Length (mm)</label>
|
||||
<input type="number" id="tabLength" value="10.0" min="1" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="tabOverlapSideGroup" style="display:none;">
|
||||
<label>Tab Overlap Side</label>
|
||||
<select id="tabOverlapSide">
|
||||
<option value="off" selected>Off</option>
|
||||
<option value="top">Top</option>
|
||||
<option value="bottom">Bottom</option>
|
||||
</select>
|
||||
<span class="field-hint">Adds one continuous outside lip across the selected edge tabs.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" role="tabpanel" data-panel="busbars">
|
||||
@@ -173,6 +178,10 @@
|
||||
</select>
|
||||
<span class="field-hint">STEP exports a 3D solid for CAD or 3D printing. DXF exports a flat 2D outline for laser or plasma cutters.</span>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="busbarCellCutoutEnabled">
|
||||
<label for="busbarCellCutoutEnabled">Cell center cutout (5x2 mm) for all busbars</label>
|
||||
</div>
|
||||
<div class="busbar-controls-row">
|
||||
<div class="busbar-face-filter" role="group" aria-label="Face filter">
|
||||
<button type="button" class="face-filter-btn active" data-filter="both">Top & Bottom</button>
|
||||
|
||||
15
node_modules/.package-lock.json
generated
vendored
15
node_modules/.package-lock.json
generated
vendored
@@ -29,9 +29,20 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
10
node_modules/.vite/deps/_metadata.json
generated
vendored
10
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"hash": "768e60fe",
|
||||
"configHash": "cddbe005",
|
||||
"lockfileHash": "6cdf80c6",
|
||||
"browserHash": "40f95f93",
|
||||
"hash": "c96d213e",
|
||||
"configHash": "c79dcb49",
|
||||
"lockfileHash": "6e69140d",
|
||||
"browserHash": "2de19426",
|
||||
"optimized": {
|
||||
"jszip": {
|
||||
"src": "../../jszip/dist/jszip.min.js",
|
||||
"file": "jszip.js",
|
||||
"fileHash": "26c0effe",
|
||||
"fileHash": "d21eabfe",
|
||||
"needsInterop": true
|
||||
}
|
||||
},
|
||||
|
||||
3
node_modules/@rollup/rollup-linux-x64-musl/README.md
generated
vendored
Normal file
3
node_modules/@rollup/rollup-linux-x64-musl/README.md
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# `@rollup/rollup-linux-x64-musl`
|
||||
|
||||
This is the **x86_64-unknown-linux-musl** binary for `rollup`
|
||||
25
node_modules/@rollup/rollup-linux-x64-musl/package.json
generated
vendored
Normal file
25
node_modules/@rollup/rollup-linux-x64-musl/package.json
generated
vendored
Normal 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"
|
||||
}
|
||||
BIN
node_modules/@rollup/rollup-linux-x64-musl/rollup.linux-x64-musl.node
generated
vendored
Normal file
BIN
node_modules/@rollup/rollup-linux-x64-musl/rollup.linux-x64-musl.node
generated
vendored
Normal file
Binary file not shown.
39
package-lock.json
generated
39
package-lock.json
generated
@@ -497,9 +497,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -514,9 +511,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -531,9 +525,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -548,9 +539,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -565,9 +553,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -582,9 +567,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -599,9 +581,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -616,9 +595,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -633,9 +609,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -650,9 +623,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -667,9 +637,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -684,9 +651,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -701,9 +665,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
368
src/app.js
368
src/app.js
@@ -23,6 +23,262 @@ export function getLastBusbarGeometries() {
|
||||
return lastComputedGeometries;
|
||||
}
|
||||
|
||||
function getEdgeTabCenters(positions, cellRadius, spacing, layoutType) {
|
||||
if (!Array.isArray(positions) || positions.length < 2) {
|
||||
return { top: [], bottom: [] };
|
||||
}
|
||||
|
||||
const rows = new Map();
|
||||
for (const [x, y] of positions) {
|
||||
const key = y.toFixed(4);
|
||||
if (!rows.has(key)) rows.set(key, []);
|
||||
rows.get(key).push([x, y]);
|
||||
}
|
||||
|
||||
const rowKeys = Array.from(rows.keys()).sort((a, b) => Number(a) - Number(b));
|
||||
if (rowKeys.length === 0) return { top: [], bottom: [] };
|
||||
|
||||
const topRow = (rows.get(rowKeys[0]) || []).slice().sort((a, b) => a[0] - b[0]);
|
||||
const bottomRow = (rows.get(rowKeys[rowKeys.length - 1]) || []).slice().sort((a, b) => a[0] - b[0]);
|
||||
const topY = Math.min(...positions.map(([, y]) => y)) - cellRadius - spacing;
|
||||
const bottomY = Math.max(...positions.map(([, y]) => y)) + cellRadius + spacing;
|
||||
const minAllX = Math.min(...positions.map(([x]) => x));
|
||||
|
||||
const topMidpoints = topRow.slice(0, -1).map((cell, index) => ({
|
||||
key: `top_${index}`,
|
||||
x: (cell[0] + topRow[index + 1][0]) / 2,
|
||||
y: topY,
|
||||
}));
|
||||
const bottomMidpoints = bottomRow.slice(0, -1).map((cell, index) => ({
|
||||
key: `bottom_${index}`,
|
||||
x: (cell[0] + bottomRow[index + 1][0]) / 2,
|
||||
y: bottomY,
|
||||
}));
|
||||
|
||||
// Grid: no extra tab on either edge
|
||||
if (layoutType === 'grid') {
|
||||
return { top: topMidpoints, bottom: bottomMidpoints };
|
||||
}
|
||||
|
||||
// Vertical honeycomb: column pitch = min X delta between any two cells
|
||||
if (layoutType === 'vertical') {
|
||||
const allXSorted = [...new Set(positions.map(([x]) => Math.round(x * 1000)))]
|
||||
.sort((a, b) => a - b).map(v => v / 1000);
|
||||
const colPitch = allXSorted.length >= 2 ? allXSorted[1] - allXSorted[0] : 0;
|
||||
// Even-column rows start at minAllX; odd-column rows are offset by colPitch
|
||||
const topIsEven = colPitch === 0 || (topRow[0][0] - minAllX) < colPitch / 2;
|
||||
const bottomIsEven = colPitch === 0 || (bottomRow[0][0] - minAllX) < colPitch / 2;
|
||||
return {
|
||||
top: topIsEven
|
||||
? topMidpoints
|
||||
: [{ key: 'top_extra_left', x: topRow[0][0] - colPitch / 2, y: topY },
|
||||
...topMidpoints,
|
||||
{ key: 'top_extra_right', x: topRow[topRow.length - 1][0] + colPitch / 2, y: topY }],
|
||||
bottom: bottomIsEven
|
||||
? bottomMidpoints
|
||||
: [{ key: 'bottom_extra_left', x: bottomRow[0][0] - colPitch / 2, y: bottomY },
|
||||
...bottomMidpoints,
|
||||
{ key: 'bottom_extra_right', x: bottomRow[bottomRow.length - 1][0] + colPitch / 2, y: bottomY }],
|
||||
};
|
||||
}
|
||||
|
||||
// Horizontal honeycomb: 1 extra tab on the side that has a gap to the wall
|
||||
const topPitch = topRow.length >= 2 ? topRow[topRow.length - 1][0] - topRow[topRow.length - 2][0] : 0;
|
||||
const bottomPitch = bottomRow.length >= 2 ? bottomRow[bottomRow.length - 1][0] - bottomRow[bottomRow.length - 2][0] : 0;
|
||||
const topExtraRight = topRow.length < 2 || (topRow[0][0] - minAllX) < topPitch / 4;
|
||||
const bottomExtraRight = bottomRow.length < 2 || (bottomRow[0][0] - minAllX) < bottomPitch / 4;
|
||||
return {
|
||||
top: topExtraRight
|
||||
? [...topMidpoints, { key: 'top_extra', x: topRow[topRow.length - 1][0] + topPitch / 2, y: topY }]
|
||||
: [{ key: 'top_extra', x: topRow[0][0] - topPitch / 2, y: topY }, ...topMidpoints],
|
||||
bottom: bottomExtraRight
|
||||
? [...bottomMidpoints, { key: 'bottom_extra', x: bottomRow[bottomRow.length - 1][0] + bottomPitch / 2, y: bottomY }]
|
||||
: [{ key: 'bottom_extra', x: bottomRow[0][0] - bottomPitch / 2, y: bottomY }, ...bottomMidpoints],
|
||||
};
|
||||
}
|
||||
|
||||
function buildBusbarAnchorCandidates(geometry, positions) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const addPoint = (x, y) => {
|
||||
const key = `${x.toFixed(4)},${y.toFixed(4)}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
candidates.push([x, y]);
|
||||
};
|
||||
|
||||
for (const index of geometry?.padIndices || []) {
|
||||
const point = positions[index];
|
||||
if (!point) continue;
|
||||
addPoint(point[0], point[1]);
|
||||
}
|
||||
|
||||
for (const edge of geometry?.edges || []) {
|
||||
const stops = [positions[edge.from], ...(edge.waypoints || []), positions[edge.to]].filter(Boolean);
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const a = stops[i];
|
||||
const b = stops[i + 1];
|
||||
addPoint((a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pad of geometry?.extraPads || []) {
|
||||
if (!Array.isArray(pad?.pos)) continue;
|
||||
addPoint(pad.pos[0], pad.pos[1]);
|
||||
}
|
||||
|
||||
for (const segment of geometry?.extraSegments || []) {
|
||||
if (!Array.isArray(segment?.from) || !Array.isArray(segment?.to)) continue;
|
||||
addPoint(segment.from[0], segment.from[1]);
|
||||
addPoint(segment.to[0], segment.to[1]);
|
||||
addPoint((segment.from[0] + segment.to[0]) / 2, (segment.from[1] + segment.to[1]) / 2);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function attachEdgeTabsToNearestBusbars(busbars, geometries, positions, options) {
|
||||
const {
|
||||
enabled = false,
|
||||
cellRadius,
|
||||
spacing,
|
||||
tabWidth,
|
||||
tabOverlapSide,
|
||||
overlapLength = 28,
|
||||
layoutType = 'honeycomb',
|
||||
} = options;
|
||||
|
||||
if (enabled !== true) return;
|
||||
if (tabOverlapSide !== 'top' && tabOverlapSide !== 'bottom') return;
|
||||
|
||||
const tabCenters = getEdgeTabCenters(positions, cellRadius, spacing, layoutType)[tabOverlapSide];
|
||||
if (tabCenters.length === 0) return;
|
||||
|
||||
const inwardDirection = tabOverlapSide === 'top' ? 1 : -1;
|
||||
const maxHorizontalGap = Math.max((tabWidth || 0), cellRadius * 2 + spacing * 2);
|
||||
const maxVerticalGap = overlapLength + cellRadius * 2 + spacing * 2;
|
||||
const betweenBusbarThreshold = Math.max((tabWidth || 0) * 0.6, cellRadius * 0.75);
|
||||
const connectorRadius = Math.max(0.05, ((tabWidth || 0) - 0.5) / 2);
|
||||
|
||||
const selectedByBusbar = [];
|
||||
|
||||
for (let i = 0; i < busbars.length; i++) {
|
||||
const busbar = busbars[i];
|
||||
const geometry = geometries[i];
|
||||
if (!geometry || geometry.blocked) continue;
|
||||
|
||||
const anchors = buildBusbarAnchorCandidates(geometry, positions);
|
||||
if (anchors.length === 0) continue;
|
||||
|
||||
const extremeY = tabOverlapSide === 'top'
|
||||
? Math.min(...anchors.map(([, y]) => y))
|
||||
: Math.max(...anchors.map(([, y]) => y));
|
||||
const edgeBand = Math.max(cellRadius * 1.1, spacing + cellRadius * 0.35);
|
||||
const edgeAnchors = anchors.filter(([, y]) => (
|
||||
tabOverlapSide === 'top' ? y <= extremeY + edgeBand : y >= extremeY - edgeBand
|
||||
));
|
||||
if (edgeAnchors.length === 0) continue;
|
||||
|
||||
let best = null;
|
||||
for (const tab of tabCenters) {
|
||||
for (const anchor of edgeAnchors) {
|
||||
const dx = Math.abs(anchor[0] - tab.x);
|
||||
const dy = Math.abs(tab.y - anchor[1]);
|
||||
if (dx > maxHorizontalGap || dy > maxVerticalGap) continue;
|
||||
const score = dx * 3 + dy;
|
||||
const candidate = {
|
||||
busbarIndex: i,
|
||||
geometry,
|
||||
anchor,
|
||||
score,
|
||||
tabKey: tab.key,
|
||||
tab,
|
||||
deltaX: anchor[0] - tab.x,
|
||||
};
|
||||
if (!best || candidate.score < best.score) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) selectedByBusbar.push(best);
|
||||
}
|
||||
|
||||
const conflictsByTab = new Map();
|
||||
for (const candidate of selectedByBusbar) {
|
||||
if (!conflictsByTab.has(candidate.tabKey)) conflictsByTab.set(candidate.tabKey, []);
|
||||
conflictsByTab.get(candidate.tabKey).push(candidate);
|
||||
}
|
||||
|
||||
for (const best of selectedByBusbar) {
|
||||
const sameTabCandidates = conflictsByTab.get(best.tabKey) || [];
|
||||
const leftCandidate = sameTabCandidates.find((candidate) => candidate.deltaX < -betweenBusbarThreshold);
|
||||
const rightCandidate = sameTabCandidates.find((candidate) => candidate.deltaX > betweenBusbarThreshold);
|
||||
if (leftCandidate && rightCandidate) continue;
|
||||
|
||||
const anchorPoint = best.anchor.slice();
|
||||
const edgePoint = [best.tab.x, best.tab.y];
|
||||
const innerPoint = [best.tab.x, best.tab.y + inwardDirection * overlapLength];
|
||||
|
||||
const busbar = busbars[best.busbarIndex];
|
||||
const overlapOutward = Number(busbar.overlapSize) > 0 ? Number(busbar.overlapSize) : 0;
|
||||
const outerPoint = overlapOutward > 0
|
||||
? [best.tab.x, best.tab.y - inwardDirection * overlapOutward]
|
||||
: null;
|
||||
|
||||
best.geometry.extraPads = Array.isArray(best.geometry.extraPads)
|
||||
? best.geometry.extraPads
|
||||
: [];
|
||||
best.geometry.extraPads.push({
|
||||
key: `bms_tab_anchor_${tabOverlapSide}_${best.tabKey}`,
|
||||
pos: anchorPoint,
|
||||
radius: connectorRadius,
|
||||
});
|
||||
best.geometry.extraPads.push({
|
||||
key: `bms_tab_edge_${tabOverlapSide}_${best.tabKey}`,
|
||||
pos: edgePoint,
|
||||
radius: connectorRadius,
|
||||
});
|
||||
best.geometry.extraPads.push({
|
||||
key: `bms_tab_inner_${tabOverlapSide}_${best.tabKey}`,
|
||||
pos: innerPoint,
|
||||
radius: connectorRadius,
|
||||
});
|
||||
if (outerPoint) {
|
||||
best.geometry.extraPads.push({
|
||||
key: `bms_tab_outer_${tabOverlapSide}_${best.tabKey}`,
|
||||
pos: outerPoint,
|
||||
radius: connectorRadius,
|
||||
});
|
||||
}
|
||||
best.geometry.extraSegments = Array.isArray(best.geometry.extraSegments)
|
||||
? best.geometry.extraSegments
|
||||
: [];
|
||||
best.geometry.extraSegments.push({
|
||||
from: anchorPoint,
|
||||
to: innerPoint,
|
||||
fromKey: `bms_tab_anchor_${tabOverlapSide}_${best.tabKey}`,
|
||||
toKey: `bms_tab_inner_${tabOverlapSide}_${best.tabKey}`,
|
||||
radius: connectorRadius,
|
||||
});
|
||||
best.geometry.extraSegments.push({
|
||||
from: edgePoint,
|
||||
to: innerPoint,
|
||||
fromKey: `bms_tab_edge_${tabOverlapSide}_${best.tabKey}`,
|
||||
toKey: `bms_tab_inner_${tabOverlapSide}_${best.tabKey}`,
|
||||
radius: connectorRadius,
|
||||
});
|
||||
if (outerPoint) {
|
||||
best.geometry.extraSegments.push({
|
||||
from: edgePoint,
|
||||
to: outerPoint,
|
||||
fromKey: `bms_tab_edge_${tabOverlapSide}_${best.tabKey}`,
|
||||
toKey: `bms_tab_outer_${tabOverlapSide}_${best.tabKey}`,
|
||||
radius: connectorRadius,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawBothCanvases(positions, cellSize, padRadius, spacing) {
|
||||
drawPreview(positions, cellSize);
|
||||
|
||||
@@ -202,9 +458,37 @@ export function updatePreview(resetView = false) {
|
||||
const cellRadius = cellSize / 2;
|
||||
const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0);
|
||||
const busbarKeepoutRadius = 4.0;
|
||||
const busbarCellCutoutEnabled = document.getElementById('busbarCellCutoutEnabled')?.checked === true;
|
||||
const packBounds = {
|
||||
left: Math.min(...positions.map(p => p[0])) - cellRadius - spacing,
|
||||
right: Math.max(...positions.map(p => p[0])) + cellRadius + spacing,
|
||||
bottom: Math.min(...positions.map(p => p[1])) - cellRadius - spacing,
|
||||
top: Math.max(...positions.map(p => p[1])) + cellRadius + spacing,
|
||||
};
|
||||
lastComputedGeometries = busbarStore.list.map(bb =>
|
||||
computeBusbarGeometry(bb.cellIndices, positions, cellRadius, busbarPadRadius, spacing, busbarKeepoutRadius)
|
||||
computeBusbarGeometry(
|
||||
bb.cellIndices,
|
||||
positions,
|
||||
cellRadius,
|
||||
busbarPadRadius,
|
||||
spacing,
|
||||
busbarKeepoutRadius,
|
||||
packBounds,
|
||||
bb.overlapEnabled !== false,
|
||||
layoutType,
|
||||
bb.overlapSize,
|
||||
busbarCellCutoutEnabled,
|
||||
)
|
||||
);
|
||||
attachEdgeTabsToNearestBusbars(busbarStore.list, lastComputedGeometries, positions, {
|
||||
enabled: document.getElementById('bmsHolesType')?.value === 'tabs',
|
||||
cellRadius,
|
||||
spacing,
|
||||
tabWidth: parseFloat(document.getElementById('height')?.value) || 10.0,
|
||||
tabOverlapSide: document.getElementById('tabOverlapSide')?.value || 'off',
|
||||
overlapLength: parseFloat(document.getElementById('height')?.value) || 10.0,
|
||||
layoutType,
|
||||
});
|
||||
lastBusbarDrawArgs = { positions, cellSize, padRadius: busbarPadRadius, spacing };
|
||||
drawBothCanvases(positions, cellSize, busbarPadRadius, spacing);
|
||||
|
||||
@@ -448,14 +732,15 @@ export async function generateLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
const tabWidth = parseFloat(document.getElementById('tabWidth').value) || 4.0;
|
||||
const tabDepth = parseFloat(document.getElementById('tabDepth').value) || 1.0;
|
||||
const tabWidth = parseFloat(document.getElementById('height').value) || 10.0;
|
||||
const tabLength = parseFloat(document.getElementById('tabLength')?.value) || 10.0;
|
||||
const tabOverlapSide = document.getElementById('tabOverlapSide')?.value || 'off';
|
||||
|
||||
const config = {
|
||||
cellSize, spacing, height, terminalDiameter, terminalDepth,
|
||||
coverThickness, roundedCorners, bmsHoles, ledgeWidth,
|
||||
filletBms, circleHoleOffset, useTabs, useFullCircles, bmsHoleDiameter,
|
||||
tabWidth, tabDepth,
|
||||
tabWidth, tabLength, tabDepth: 1.0, tabOverlapSide, layoutType,
|
||||
};
|
||||
|
||||
const holderShape = create3DModel(positions, config);
|
||||
@@ -467,9 +752,37 @@ export async function generateLayout() {
|
||||
|
||||
const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0);
|
||||
const busbarKeepoutRadius = terminalDiameter / 2;
|
||||
const busbarCellCutoutEnabled = document.getElementById('busbarCellCutoutEnabled')?.checked === true;
|
||||
const packBounds = {
|
||||
left: Math.min(...positions.map(p => p[0])) - cellRadius - spacing,
|
||||
right: Math.max(...positions.map(p => p[0])) + cellRadius + spacing,
|
||||
bottom: Math.min(...positions.map(p => p[1])) - cellRadius - spacing,
|
||||
top: Math.max(...positions.map(p => p[1])) + cellRadius + spacing,
|
||||
};
|
||||
const busbarGeometries = busbarStore.list.map(bb =>
|
||||
computeBusbarGeometry(bb.cellIndices, positions, cellRadius, busbarPadRadius, spacing, busbarKeepoutRadius)
|
||||
computeBusbarGeometry(
|
||||
bb.cellIndices,
|
||||
positions,
|
||||
cellRadius,
|
||||
busbarPadRadius,
|
||||
spacing,
|
||||
busbarKeepoutRadius,
|
||||
packBounds,
|
||||
bb.overlapEnabled !== false,
|
||||
layoutType,
|
||||
bb.overlapSize,
|
||||
busbarCellCutoutEnabled,
|
||||
)
|
||||
);
|
||||
attachEdgeTabsToNearestBusbars(busbarStore.list, busbarGeometries, positions, {
|
||||
enabled: bmsHolesType === 'tabs',
|
||||
cellRadius,
|
||||
spacing,
|
||||
tabWidth,
|
||||
tabOverlapSide,
|
||||
overlapLength: height,
|
||||
layoutType,
|
||||
});
|
||||
|
||||
for (let i = 0; i < busbarStore.list.length; i++) {
|
||||
const bb = busbarStore.list[i];
|
||||
@@ -492,10 +805,18 @@ export async function generateLayout() {
|
||||
// signatures are congruent, so we only need to print one copy. Uses sorted
|
||||
// pairwise cell distances; pairwise distance sets are the same under
|
||||
// translation, rotation, and mirror.
|
||||
const busbarSignature = (bb) => {
|
||||
const busbarSignature = (bb, geom) => {
|
||||
const idxs = bb.cellIndices;
|
||||
const cellCutoutEnabled = document.getElementById('busbarCellCutoutEnabled')?.checked === true;
|
||||
const tabSegments = (Array.isArray(geom?.extraSegments) ? geom.extraSegments : [])
|
||||
.filter((segment) => String(segment?.fromKey || '').startsWith('bms_tab_') || String(segment?.toKey || '').startsWith('bms_tab_'))
|
||||
.map((segment) => `${segment.from[0].toFixed(3)},${segment.from[1].toFixed(3)}>${segment.to[0].toFixed(3)},${segment.to[1].toFixed(3)}`)
|
||||
.sort()
|
||||
.join(';');
|
||||
if (idxs.length === 0) return null;
|
||||
if (idxs.length === 1) return `single|${bb.thickness.toFixed(2)}`;
|
||||
if (idxs.length === 1) {
|
||||
return `single|${bb.thickness.toFixed(2)}|ov:${bb.overlapEnabled === true ? 1 : 0}|os:${Number(bb.overlapSize ?? 10).toFixed(2)}|cc:${cellCutoutEnabled ? 1 : 0}|tabs:${tabSegments}`;
|
||||
}
|
||||
const pts = idxs.map(i => centeredPositions[i]).filter(Boolean);
|
||||
const dists = [];
|
||||
for (let a = 0; a < pts.length; a++) {
|
||||
@@ -504,7 +825,7 @@ export async function generateLayout() {
|
||||
}
|
||||
}
|
||||
dists.sort((x, y) => x - y);
|
||||
return `${pts.length}|${bb.thickness.toFixed(2)}|${dists.map(d => d.toFixed(3)).join(',')}`;
|
||||
return `${pts.length}|${bb.thickness.toFixed(2)}|ov:${bb.overlapEnabled === true ? 1 : 0}|os:${Number(bb.overlapSize ?? 10).toFixed(2)}|cc:${cellCutoutEnabled ? 1 : 0}|tabs:${tabSegments}|${dists.map(d => d.toFixed(3)).join(',')}`;
|
||||
};
|
||||
|
||||
// Export format for busbars: STEP solid or DXF flat pattern. The cellholder
|
||||
@@ -518,14 +839,14 @@ export async function generateLayout() {
|
||||
for (let i = 0; i < busbarStore.list.length; i++) {
|
||||
const bb = busbarStore.list[i];
|
||||
if (bb.cellIndices.length === 0) continue;
|
||||
const sig = busbarSignature(bb);
|
||||
const sig = busbarSignature(bb, busbarGeometries[i]);
|
||||
if (sig && sigSeen.has(sig)) {
|
||||
sigSeen.get(sig).copies.push(bb.name);
|
||||
continue;
|
||||
}
|
||||
const entry = { bb, geom: busbarGeometries[i], copies: [bb.name], shape: null };
|
||||
if (busbarFormat === 'step') {
|
||||
entry.shape = build3DBusbar(busbarGeometries[i], centeredPositions, busbarPadRadius, height, bb.thickness);
|
||||
entry.shape = build3DBusbar(centeredGeom(busbarGeometries[i], holderCenterX, holderCenterY), centeredPositions, busbarPadRadius, height, bb.thickness);
|
||||
if (!entry.shape) continue;
|
||||
}
|
||||
uniqueBusbars.push(entry);
|
||||
@@ -540,7 +861,7 @@ export async function generateLayout() {
|
||||
const { bb, geom, shape } = uniqueBusbars[i];
|
||||
const base = `busbar_${safeName(bb.name)}`;
|
||||
if (busbarFormat === 'dxf') {
|
||||
const content = buildBusbarDXF(geom, centeredPositions, busbarPadRadius);
|
||||
const content = buildBusbarDXF(centeredGeom(geom, holderCenterX, holderCenterY), centeredPositions, busbarPadRadius);
|
||||
downloadDXF(content, `${base}.dxf`);
|
||||
} else {
|
||||
downloadSTEP(shape, `${base}.step`);
|
||||
@@ -570,6 +891,21 @@ export async function generateLayout() {
|
||||
|
||||
// ── Per-busbar download helpers ────────────────────────────────────────────────
|
||||
|
||||
// Return a copy of geom with extraPads, extraSegments, and cutouts shifted by (-cx, -cy).
|
||||
function centeredGeom(geom, cx, cy) {
|
||||
if (!geom) return geom;
|
||||
const cutouts = Array.isArray(geom.cutouts)
|
||||
? geom.cutouts.map(c => ({ ...c, center: [c.center[0] - cx, c.center[1] - cy] }))
|
||||
: geom.cutouts;
|
||||
const extraPads = Array.isArray(geom.extraPads)
|
||||
? geom.extraPads.map(p => ({ ...p, pos: [p.pos[0] - cx, p.pos[1] - cy] }))
|
||||
: geom.extraPads;
|
||||
const extraSegments = Array.isArray(geom.extraSegments)
|
||||
? geom.extraSegments.map(s => ({ ...s, from: [s.from[0] - cx, s.from[1] - cy], to: [s.to[0] - cx, s.to[1] - cy] }))
|
||||
: geom.extraSegments;
|
||||
return { ...geom, cutouts, extraPads, extraSegments };
|
||||
}
|
||||
|
||||
function getBusbarExportContext() {
|
||||
if (!lastBusbarDrawArgs || lastComputedGeometries.length === 0) return null;
|
||||
const { positions, padRadius } = lastBusbarDrawArgs;
|
||||
@@ -579,7 +915,7 @@ function getBusbarExportContext() {
|
||||
const height = parseFloat(document.getElementById('height').value);
|
||||
const busbarFormat = document.getElementById('busbarFormat')?.value || 'step';
|
||||
const safeName = (name) => (name || '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'busbar';
|
||||
return { centeredPositions, padRadius, height, busbarFormat, safeName };
|
||||
return { centeredPositions, centerX: cx, centerY: cy, padRadius, height, busbarFormat, safeName };
|
||||
}
|
||||
|
||||
export async function downloadSingleBusbar(busbarId) {
|
||||
@@ -609,10 +945,10 @@ export async function downloadSingleBusbar(busbarId) {
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
try {
|
||||
if (ctx.busbarFormat === 'dxf') {
|
||||
const content = buildBusbarDXF(geom, ctx.centeredPositions, ctx.padRadius);
|
||||
const content = buildBusbarDXF(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius);
|
||||
downloadDXF(content, `${base}.dxf`);
|
||||
} else {
|
||||
const shape = build3DBusbar(geom, ctx.centeredPositions, ctx.padRadius, ctx.height, bb.thickness);
|
||||
const shape = build3DBusbar(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius, ctx.height, bb.thickness);
|
||||
if (!shape) { showStatus(`Failed to build 3D shape for ${bb.name}.`, 'error'); return; }
|
||||
downloadSTEP(shape, `${base}.step`);
|
||||
}
|
||||
@@ -648,10 +984,10 @@ export async function downloadAllBusbarsZip() {
|
||||
for (const { bb, geom } of eligible) {
|
||||
const base = `busbar_${ctx.safeName(bb.name)}`;
|
||||
if (ctx.busbarFormat === 'dxf') {
|
||||
const content = buildBusbarDXF(geom, ctx.centeredPositions, ctx.padRadius);
|
||||
const content = buildBusbarDXF(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius);
|
||||
zip.file(`${base}.dxf`, content);
|
||||
} else {
|
||||
const shape = build3DBusbar(geom, ctx.centeredPositions, ctx.padRadius, ctx.height, bb.thickness);
|
||||
const shape = build3DBusbar(centeredGeom(geom, ctx.centerX, ctx.centerY), ctx.centeredPositions, ctx.padRadius, ctx.height, bb.thickness);
|
||||
if (!shape) continue;
|
||||
const bytes = buildSTEPBytes(shape, `_zip_${base}.step`);
|
||||
if (bytes) zip.file(`${base}.step`, bytes);
|
||||
|
||||
@@ -155,12 +155,238 @@ function smoothPolylinePoints(points, padRadius) {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRadius, spacing, keepoutRadius) {
|
||||
function inferHorizontalPitch(positions) {
|
||||
const epsilon = 1e-3;
|
||||
const rows = new Map();
|
||||
for (const pos of positions) {
|
||||
const key = pos[1].toFixed(4);
|
||||
if (!rows.has(key)) rows.set(key, []);
|
||||
rows.get(key).push(pos[0]);
|
||||
}
|
||||
|
||||
let pitch = Infinity;
|
||||
for (const rowXs of rows.values()) {
|
||||
rowXs.sort((a, b) => a - b);
|
||||
for (let index = 1; index < rowXs.length; index++) {
|
||||
const delta = rowXs[index] - rowXs[index - 1];
|
||||
if (delta > epsilon) pitch = Math.min(pitch, delta);
|
||||
}
|
||||
}
|
||||
|
||||
return Number.isFinite(pitch) ? pitch : 0;
|
||||
}
|
||||
|
||||
function computeBoundaryRoundoverFeatures(cellIndices, positions, padRadius) {
|
||||
if (cellIndices.length < 2) {
|
||||
return { extraPads: [], extraSegments: [] };
|
||||
}
|
||||
|
||||
const selected = cellIndices
|
||||
.map((index) => ({ index, pos: positions[index] }))
|
||||
.filter((entry) => Array.isArray(entry.pos) && entry.pos.length >= 2);
|
||||
if (selected.length !== cellIndices.length) {
|
||||
return { extraPads: [], extraSegments: [] };
|
||||
}
|
||||
|
||||
const epsilon = 1e-3;
|
||||
const horizontalPitch = inferHorizontalPitch(positions);
|
||||
if (horizontalPitch <= epsilon) {
|
||||
return { extraPads: [], extraSegments: [] };
|
||||
}
|
||||
|
||||
const xTolerance = Math.max(0.5, horizontalPitch * 0.25);
|
||||
const yTolerance = 1e-3;
|
||||
const hasHorizontalNeighbor = (entry, direction) => positions.some((pos) => {
|
||||
if (Math.abs(pos[1] - entry.pos[1]) > yTolerance) return false;
|
||||
const deltaX = pos[0] - entry.pos[0];
|
||||
if (direction === 'left' && deltaX >= -epsilon) return false;
|
||||
if (direction === 'right' && deltaX <= epsilon) return false;
|
||||
return Math.abs(Math.abs(deltaX) - horizontalPitch) <= xTolerance;
|
||||
});
|
||||
|
||||
const rows = new Map();
|
||||
for (const entry of selected) {
|
||||
const key = entry.pos[1].toFixed(4);
|
||||
if (!rows.has(key)) rows.set(key, []);
|
||||
rows.get(key).push(entry);
|
||||
}
|
||||
|
||||
const leftBoundary = [];
|
||||
const rightBoundary = [];
|
||||
for (const rowEntries of rows.values()) {
|
||||
rowEntries.sort((a, b) => a.pos[0] - b.pos[0]);
|
||||
leftBoundary.push(rowEntries[0]);
|
||||
rightBoundary.push(rowEntries[rowEntries.length - 1]);
|
||||
}
|
||||
|
||||
const leftExposed = leftBoundary.filter((entry) => !hasHorizontalNeighbor(entry, 'left'));
|
||||
const rightExposed = rightBoundary.filter((entry) => !hasHorizontalNeighbor(entry, 'right'));
|
||||
const extraPads = [];
|
||||
const extraSegments = [];
|
||||
const fillRadius = Math.max(0.2, padRadius * 0.55);
|
||||
const outwardShift = Math.max(0.2, padRadius * 0.22);
|
||||
|
||||
const addSideRoundovers = (entries, side) => {
|
||||
const direction = side === 'left' ? -1 : 1;
|
||||
const ordered = entries.slice().sort((a, b) => a.pos[1] - b.pos[1]);
|
||||
for (let index = 0; index < ordered.length - 1; index++) {
|
||||
const top = ordered[index];
|
||||
const bottom = ordered[index + 1];
|
||||
const fillKey = `boundary_round_${side}_${index}`;
|
||||
const fillPos = [
|
||||
(top.pos[0] + bottom.pos[0]) / 2 + direction * outwardShift,
|
||||
(top.pos[1] + bottom.pos[1]) / 2,
|
||||
];
|
||||
extraPads.push({ key: fillKey, pos: fillPos, radius: fillRadius });
|
||||
extraSegments.push({ from: fillPos, to: top.pos, fromKey: fillKey, toKey: `c${top.index}`, radius: fillRadius });
|
||||
extraSegments.push({ from: fillPos, to: bottom.pos, fromKey: fillKey, toKey: `c${bottom.index}`, radius: fillRadius });
|
||||
}
|
||||
};
|
||||
|
||||
addSideRoundovers(leftExposed, 'left');
|
||||
addSideRoundovers(rightExposed, 'right');
|
||||
|
||||
return { extraPads, extraSegments };
|
||||
}
|
||||
|
||||
function computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapLength) {
|
||||
if (cellIndices.length < 2) {
|
||||
return { extraPads: [], extraSegments: [] };
|
||||
}
|
||||
|
||||
const selected = cellIndices
|
||||
.map((index) => ({ index, pos: positions[index] }))
|
||||
.filter((entry) => Array.isArray(entry.pos) && entry.pos.length >= 2);
|
||||
if (selected.length !== cellIndices.length) {
|
||||
return { extraPads: [], extraSegments: [] };
|
||||
}
|
||||
|
||||
const epsilon = 1e-3;
|
||||
const horizontalPitch = inferHorizontalPitch(positions);
|
||||
if (horizontalPitch <= epsilon) {
|
||||
return { extraPads: [], extraSegments: [] };
|
||||
}
|
||||
|
||||
const xTolerance = Math.max(0.5, horizontalPitch * 0.25);
|
||||
const yTolerance = 1e-3;
|
||||
const hasHorizontalNeighbor = (entry, direction) => positions.some((pos) => {
|
||||
if (Math.abs(pos[1] - entry.pos[1]) > yTolerance) return false;
|
||||
const deltaX = pos[0] - entry.pos[0];
|
||||
if (direction === 'left' && deltaX >= -epsilon) return false;
|
||||
if (direction === 'right' && deltaX <= epsilon) return false;
|
||||
return Math.abs(Math.abs(deltaX) - horizontalPitch) <= xTolerance;
|
||||
});
|
||||
|
||||
const rows = new Map();
|
||||
for (const entry of selected) {
|
||||
const key = entry.pos[1].toFixed(4);
|
||||
if (!rows.has(key)) rows.set(key, []);
|
||||
rows.get(key).push(entry);
|
||||
}
|
||||
|
||||
const leftBoundary = [];
|
||||
const rightBoundary = [];
|
||||
for (const rowEntries of rows.values()) {
|
||||
rowEntries.sort((a, b) => a.pos[0] - b.pos[0]);
|
||||
leftBoundary.push(rowEntries[0]);
|
||||
rightBoundary.push(rowEntries[rowEntries.length - 1]);
|
||||
}
|
||||
|
||||
const leftExposed = leftBoundary.filter((entry) => !hasHorizontalNeighbor(entry, 'left'));
|
||||
const rightExposed = rightBoundary.filter((entry) => !hasHorizontalNeighbor(entry, 'right'));
|
||||
|
||||
let chosenSide = null;
|
||||
let boundaryEntries = [];
|
||||
if (leftExposed.length > rightExposed.length && leftExposed.length > 0) {
|
||||
chosenSide = 'left';
|
||||
boundaryEntries = leftExposed;
|
||||
} else if (rightExposed.length > leftExposed.length && rightExposed.length > 0) {
|
||||
chosenSide = 'right';
|
||||
boundaryEntries = rightExposed;
|
||||
}
|
||||
|
||||
if (!chosenSide || boundaryEntries.length === 0) {
|
||||
return { extraPads: [], extraSegments: [] };
|
||||
}
|
||||
|
||||
const extension = Number.isFinite(Number(overlapLength)) && Number(overlapLength) > 0
|
||||
? Number(overlapLength)
|
||||
: 10;
|
||||
const direction = chosenSide === 'left' ? -1 : 1;
|
||||
const straightHoneycombX = layoutType === 'honeycomb'
|
||||
? (direction < 0
|
||||
? Math.min(...boundaryEntries.map((entry) => entry.pos[0])) - extension
|
||||
: Math.max(...boundaryEntries.map((entry) => entry.pos[0])) + extension)
|
||||
: null;
|
||||
const extraPads = [];
|
||||
const extraSegments = [];
|
||||
const overlapPads = boundaryEntries
|
||||
.slice()
|
||||
.sort((a, b) => a.pos[1] - b.pos[1])
|
||||
.map((entry, index) => {
|
||||
const key = `edge_overlap_${index}`;
|
||||
const overlapPos = [
|
||||
straightHoneycombX ?? (entry.pos[0] + direction * extension),
|
||||
entry.pos[1],
|
||||
];
|
||||
extraPads.push({ key, pos: overlapPos });
|
||||
extraSegments.push({ from: overlapPos, to: entry.pos, fromKey: key, toKey: `c${entry.index}` });
|
||||
return { key, pos: overlapPos };
|
||||
});
|
||||
|
||||
for (let index = 0; index < overlapPads.length - 1; index++) {
|
||||
const topOverlapPad = overlapPads[index];
|
||||
const bottomOverlapPad = overlapPads[index + 1];
|
||||
const topCell = boundaryEntries[index];
|
||||
const bottomCell = boundaryEntries[index + 1];
|
||||
|
||||
extraSegments.push({
|
||||
from: topOverlapPad.pos,
|
||||
to: bottomOverlapPad.pos,
|
||||
fromKey: topOverlapPad.key,
|
||||
toKey: bottomOverlapPad.key,
|
||||
});
|
||||
|
||||
// Large overlap offsets open a trapezoid between two neighboring connector
|
||||
// legs and the outer spine. Add one interior hub and connect it to the
|
||||
// bay corners so the shared pad/capsule geometry stays fully filled.
|
||||
const fillKey = `edge_overlap_fill_${index}`;
|
||||
const fillPos = [
|
||||
(topOverlapPad.pos[0] + bottomOverlapPad.pos[0] + topCell.pos[0] + bottomCell.pos[0]) / 4,
|
||||
(topOverlapPad.pos[1] + bottomOverlapPad.pos[1] + topCell.pos[1] + bottomCell.pos[1]) / 4,
|
||||
];
|
||||
extraPads.push({ key: fillKey, pos: fillPos });
|
||||
extraSegments.push({ from: fillPos, to: topOverlapPad.pos, fromKey: fillKey, toKey: topOverlapPad.key });
|
||||
extraSegments.push({ from: fillPos, to: bottomOverlapPad.pos, fromKey: fillKey, toKey: bottomOverlapPad.key });
|
||||
extraSegments.push({ from: fillPos, to: topCell.pos, fromKey: fillKey, toKey: `c${topCell.index}` });
|
||||
extraSegments.push({ from: fillPos, to: bottomCell.pos, fromKey: fillKey, toKey: `c${bottomCell.index}` });
|
||||
}
|
||||
|
||||
return { extraPads, extraSegments };
|
||||
}
|
||||
|
||||
function computeCellCutouts(cellIndices, positions, cellCutoutEnabled) {
|
||||
if (cellCutoutEnabled !== true) return [];
|
||||
|
||||
return cellIndices
|
||||
.map((index) => positions[index])
|
||||
.filter((pos) => Array.isArray(pos) && pos.length >= 2)
|
||||
.map((pos) => ({ center: pos.slice(), width: 5, height: 2 }));
|
||||
}
|
||||
|
||||
export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRadius, spacing, keepoutRadius, packBounds = null, overlapEnabled = true, layoutType = 'grid', overlapSize = 10, cellCutoutEnabled = false) {
|
||||
if (cellIndices.length === 0) {
|
||||
return { padIndices: [], edges: [], blocked: null };
|
||||
return { padIndices: [], edges: [], blocked: null, extraPads: [], extraSegments: [], cutouts: [] };
|
||||
}
|
||||
if (cellIndices.length === 1) {
|
||||
return { padIndices: cellIndices.slice(), edges: [], blocked: null };
|
||||
return {
|
||||
padIndices: cellIndices.slice(),
|
||||
edges: [],
|
||||
blocked: null,
|
||||
extraPads: [],
|
||||
extraSegments: [],
|
||||
cutouts: computeCellCutouts(cellIndices, positions, cellCutoutEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
const edgePairs = buildEdgePairs(cellIndices, positions, cellRadius, spacing);
|
||||
@@ -185,6 +411,9 @@ export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRad
|
||||
padIndices: cellIndices.slice(),
|
||||
edges,
|
||||
blocked: { from: i, to: j, reason: 'no clear route between these cells' },
|
||||
extraPads: [],
|
||||
extraSegments: [],
|
||||
cutouts: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -200,5 +429,18 @@ export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRad
|
||||
};
|
||||
});
|
||||
|
||||
return { padIndices: cellIndices.slice(), edges: roundedEdges, blocked: null };
|
||||
const overlapFeatures = overlapEnabled
|
||||
? computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapSize)
|
||||
: { extraPads: [], extraSegments: [] };
|
||||
const roundoverFeatures = computeBoundaryRoundoverFeatures(cellIndices, positions, padRadius);
|
||||
const cutouts = computeCellCutouts(cellIndices, positions, cellCutoutEnabled);
|
||||
|
||||
return {
|
||||
padIndices: cellIndices.slice(),
|
||||
edges: roundedEdges,
|
||||
blocked: null,
|
||||
extraPads: [...overlapFeatures.extraPads, ...roundoverFeatures.extraPads],
|
||||
extraSegments: [...overlapFeatures.extraSegments, ...roundoverFeatures.extraSegments],
|
||||
cutouts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ export function build3DBusbar(geometry, positions, padRadius, zBase, thickness)
|
||||
if (!oc || geometry.padIndices.length === 0) return null;
|
||||
|
||||
const shapes = [];
|
||||
const extraPads = Array.isArray(geometry.extraPads) ? geometry.extraPads : [];
|
||||
const extraSegments = Array.isArray(geometry.extraSegments) ? geometry.extraSegments : [];
|
||||
const cutouts = Array.isArray(geometry.cutouts) ? geometry.cutouts : [];
|
||||
|
||||
for (const i of geometry.padIndices) {
|
||||
if (!positions[i]) continue;
|
||||
@@ -13,6 +16,13 @@ export function build3DBusbar(geometry, positions, padRadius, zBase, thickness)
|
||||
shapes.push(new oc.BRepPrimAPI_MakeCylinder(ax, padRadius, thickness).Shape());
|
||||
}
|
||||
|
||||
for (const pad of extraPads) {
|
||||
const [x, y] = pad.pos;
|
||||
const radius = pad.radius ?? padRadius;
|
||||
const ax = new oc.gp_Ax2(new oc.gp_Pnt(x, y, zBase), oc.gp.prototype.DZ());
|
||||
shapes.push(new oc.BRepPrimAPI_MakeCylinder(ax, radius, thickness).Shape());
|
||||
}
|
||||
|
||||
for (const edge of geometry.edges) {
|
||||
const pts = [positions[edge.from], ...edge.waypoints, positions[edge.to]];
|
||||
|
||||
@@ -48,10 +58,49 @@ export function build3DBusbar(geometry, positions, padRadius, zBase, thickness)
|
||||
}
|
||||
}
|
||||
|
||||
for (const segment of extraSegments) {
|
||||
const [x1, y1] = segment.from;
|
||||
const [x2, y2] = segment.to;
|
||||
const segmentRadius = segment.radius ?? padRadius;
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-6) continue;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
const box = new oc.BRepPrimAPI_MakeBox(len, 2 * segmentRadius, thickness).Shape();
|
||||
|
||||
const trans1 = new oc.gp_Trsf();
|
||||
trans1.SetTranslation(new oc.gp_Vec(0, -segmentRadius, 0));
|
||||
let shape = new oc.BRepBuilderAPI_Transform(box, trans1, false).Shape();
|
||||
|
||||
const rot = new oc.gp_Trsf();
|
||||
rot.SetRotation(new oc.gp_Ax1(new oc.gp_Pnt(0, 0, 0), oc.gp.prototype.DZ()), angle);
|
||||
shape = new oc.BRepBuilderAPI_Transform(shape, rot, false).Shape();
|
||||
|
||||
const trans2 = new oc.gp_Trsf();
|
||||
trans2.SetTranslation(new oc.gp_Vec(x1, y1, zBase));
|
||||
shape = new oc.BRepBuilderAPI_Transform(shape, trans2, false).Shape();
|
||||
|
||||
shapes.push(shape);
|
||||
}
|
||||
|
||||
if (shapes.length === 0) return null;
|
||||
let combined = shapes[0];
|
||||
for (let i = 1; i < shapes.length; i++) {
|
||||
combined = new oc.BRepAlgoAPI_Fuse(combined, shapes[i]).Shape();
|
||||
}
|
||||
|
||||
for (const cutout of cutouts) {
|
||||
const [cx, cy] = cutout.center;
|
||||
const width = cutout.width;
|
||||
const height = cutout.height;
|
||||
const cutter = new oc.BRepPrimAPI_MakeBox(width, height, thickness).Shape();
|
||||
|
||||
const trans = new oc.gp_Trsf();
|
||||
trans.SetTranslation(new oc.gp_Vec(cx - width / 2, cy - height / 2, zBase));
|
||||
const placedCutter = new oc.BRepBuilderAPI_Transform(cutter, trans, false).Shape();
|
||||
combined = new oc.BRepAlgoAPI_Cut(combined, placedCutter).Shape();
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
|
||||
const cellR = cellSize / 2;
|
||||
const toScreenX = (wx) => (wx - t.minX + cellR + spacing) * t.scale + t.offsetX;
|
||||
const toScreenY = (wy) => (wy - t.minY + cellR + spacing) * t.scale + t.offsetY;
|
||||
const padRadiusScreen = padRadius * t.scale;
|
||||
const padRadiusScreen = (radius) => (radius ?? padRadius) * t.scale;
|
||||
const extraSegments = (geom) => Array.isArray(geom?.extraSegments) ? geom.extraSegments : [];
|
||||
const extraPads = (geom) => Array.isArray(geom?.extraPads) ? geom.extraPads : [];
|
||||
const cutouts = (geom) => Array.isArray(geom?.cutouts) ? geom.cutouts : [];
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(canvasState.panX, canvasState.panY);
|
||||
@@ -81,7 +84,7 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
|
||||
// 1. Capsule strokes between direct neighbours (lineCap:round = semicircle
|
||||
// at each end, so two meeting capsules form a full circle at every junction).
|
||||
off.strokeStyle = opaqueColor;
|
||||
off.lineWidth = 2 * padRadiusScreen;
|
||||
off.lineWidth = 2 * padRadiusScreen();
|
||||
off.lineCap = 'round';
|
||||
off.lineJoin = 'round';
|
||||
for (const [a, b] of adjCaps) {
|
||||
@@ -91,22 +94,36 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
|
||||
off.lineTo(toScreenX(pb[0]), toScreenY(pb[1]));
|
||||
off.stroke();
|
||||
}
|
||||
for (const segment of extraSegments(geom)) {
|
||||
off.lineWidth = 2 * padRadiusScreen(segment.radius);
|
||||
off.beginPath();
|
||||
off.moveTo(toScreenX(segment.from[0]), toScreenY(segment.from[1]));
|
||||
off.lineTo(toScreenX(segment.to[0]), toScreenY(segment.to[1]));
|
||||
off.stroke();
|
||||
}
|
||||
|
||||
// 2. Circle fills at every cell pad.
|
||||
off.fillStyle = opaqueColor;
|
||||
off.beginPath();
|
||||
for (const ci of cellIndices) {
|
||||
const p = positions[ci]; if (!p) continue;
|
||||
const radius = padRadiusScreen();
|
||||
const sx = toScreenX(p[0]), sy = toScreenY(p[1]);
|
||||
off.moveTo(sx + padRadiusScreen, sy);
|
||||
off.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2);
|
||||
off.moveTo(sx + radius, sy);
|
||||
off.arc(sx, sy, radius, 0, Math.PI * 2);
|
||||
}
|
||||
for (const pad of extraPads(geom)) {
|
||||
const radius = padRadiusScreen(pad.radius);
|
||||
const sx = toScreenX(pad.pos[0]);
|
||||
const sy = toScreenY(pad.pos[1]);
|
||||
off.moveTo(sx + radius, sy);
|
||||
off.arc(sx, sy, radius, 0, Math.PI * 2);
|
||||
}
|
||||
off.fill();
|
||||
|
||||
// 3. Triangle fills for every mutually-adjacent triplet.
|
||||
// The centre of three packed circles (the "interstice") lies outside all
|
||||
// three circles AND all three capsules, so without this step those points
|
||||
// remain unfilled and appear as sharp V-notches.
|
||||
// 3. Fill the three-circle interstice without extending to the cell centers.
|
||||
// Use curved joins through the centroid so boundary bays stay rounded
|
||||
// instead of forming straight-sided inward peaks.
|
||||
off.fillStyle = opaqueColor;
|
||||
for (let a = 0; a < nCells; a++) {
|
||||
for (const b of triAdj[a]) {
|
||||
@@ -118,10 +135,43 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
|
||||
const pb = positions[cellIndices[b]];
|
||||
const pc = positions[cellIndices[c]];
|
||||
if (!pa || !pb || !pc) continue;
|
||||
|
||||
const centroid = [
|
||||
(pa[0] + pb[0] + pc[0]) / 3,
|
||||
(pa[1] + pb[1] + pc[1]) / 3,
|
||||
];
|
||||
const tangentPoints = [pa, pb, pc].map((point) => {
|
||||
const dx = point[0] - centroid[0];
|
||||
const dy = point[1] - centroid[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-6 || len <= padRadius) return point;
|
||||
const inset = padRadius / len;
|
||||
return [
|
||||
point[0] - dx * inset,
|
||||
point[1] - dy * inset,
|
||||
];
|
||||
});
|
||||
|
||||
off.beginPath();
|
||||
off.moveTo(toScreenX(pa[0]), toScreenY(pa[1]));
|
||||
off.lineTo(toScreenX(pb[0]), toScreenY(pb[1]));
|
||||
off.lineTo(toScreenX(pc[0]), toScreenY(pc[1]));
|
||||
off.moveTo(toScreenX(tangentPoints[0][0]), toScreenY(tangentPoints[0][1]));
|
||||
off.quadraticCurveTo(
|
||||
toScreenX(centroid[0]),
|
||||
toScreenY(centroid[1]),
|
||||
toScreenX(tangentPoints[1][0]),
|
||||
toScreenY(tangentPoints[1][1]),
|
||||
);
|
||||
off.quadraticCurveTo(
|
||||
toScreenX(centroid[0]),
|
||||
toScreenY(centroid[1]),
|
||||
toScreenX(tangentPoints[2][0]),
|
||||
toScreenY(tangentPoints[2][1]),
|
||||
);
|
||||
off.quadraticCurveTo(
|
||||
toScreenX(centroid[0]),
|
||||
toScreenY(centroid[1]),
|
||||
toScreenX(tangentPoints[0][0]),
|
||||
toScreenY(tangentPoints[0][1]),
|
||||
);
|
||||
off.closePath();
|
||||
off.fill();
|
||||
}
|
||||
@@ -130,7 +180,7 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
|
||||
|
||||
// 4. Obstacle-avoidance detour waypoints (spanning-tree edges with bends).
|
||||
off.strokeStyle = opaqueColor;
|
||||
off.lineWidth = 2 * padRadiusScreen;
|
||||
off.lineWidth = 2 * padRadiusScreen();
|
||||
off.lineCap = 'round';
|
||||
for (const edge of geom.edges) {
|
||||
if (edge.waypoints.length === 0) continue;
|
||||
@@ -142,6 +192,17 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
|
||||
off.stroke();
|
||||
}
|
||||
|
||||
off.save();
|
||||
off.globalCompositeOperation = 'destination-out';
|
||||
for (const cutout of cutouts(geom)) {
|
||||
const width = cutout.width * t.scale;
|
||||
const height = cutout.height * t.scale;
|
||||
const cx = toScreenX(cutout.center[0]);
|
||||
const cy = toScreenY(cutout.center[1]);
|
||||
off.fillRect(cx - width / 2, cy - height / 2, width, height);
|
||||
}
|
||||
off.restore();
|
||||
|
||||
// Composite offscreen onto main canvas at fillAlpha (reset transform for pixel-exact drawImage)
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
@@ -158,7 +219,7 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
|
||||
const [x, y] = positions[i];
|
||||
const sx = toScreenX(x), sy = toScreenY(y);
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2);
|
||||
ctx.arc(sx, sy, padRadiusScreen(), 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,22 @@ function buildBusbarRow(bb, blockedByBusbarId) {
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="busbar-header">
|
||||
<div class="busbar-swatch" style="background:${bb.color}"></div>
|
||||
<label class="busbar-color-wrap" title="Busbar color">
|
||||
<input class="busbar-color" type="color" value="${escapeHtml(bb.color)}" aria-label="Busbar color for ${escapeHtml(bb.name)}">
|
||||
</label>
|
||||
<input class="busbar-name" type="text" value="${escapeHtml(bb.name)}">
|
||||
<button class="busbar-dl" title="Download this busbar">${DL_ICON}</button>
|
||||
<button class="busbar-del" title="Delete">×</button>
|
||||
</div>
|
||||
<div class="busbar-meta">
|
||||
<span class="busbar-count">${bb.cellIndices.length} cell${bb.cellIndices.length === 1 ? '' : 's'}</span>
|
||||
<label class="busbar-overlap-label">
|
||||
<input class="busbar-overlap" type="checkbox" ${bb.overlapEnabled === true ? 'checked' : ''}>
|
||||
Overlap
|
||||
</label>
|
||||
<label class="busbar-overlap-size-label">Size
|
||||
<input class="busbar-overlap-size" type="number" value="${bb.overlapSize ?? 10}" step="0.5" min="0.5" ${bb.overlapEnabled === true ? '' : 'disabled'}>
|
||||
</label>
|
||||
<label class="busbar-thickness-label">Thickness
|
||||
<input class="busbar-thickness" type="number" value="${bb.thickness}" step="0.1" min="0.1">
|
||||
</label>
|
||||
@@ -52,6 +61,16 @@ function buildBusbarRow(bb, blockedByBusbarId) {
|
||||
row.querySelector('.busbar-name').addEventListener('change', (e) => {
|
||||
busbarStore.rename(bb.id, e.target.value);
|
||||
});
|
||||
row.querySelector('.busbar-color').addEventListener('input', (e) => {
|
||||
busbarStore.setColor(bb.id, e.target.value);
|
||||
});
|
||||
row.querySelector('.busbar-overlap').addEventListener('change', (e) => {
|
||||
busbarStore.setOverlapEnabled(bb.id, e.target.checked);
|
||||
});
|
||||
row.querySelector('.busbar-overlap-size').addEventListener('change', (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
if (v > 0) busbarStore.setOverlapSize(bb.id, v);
|
||||
});
|
||||
row.querySelector('.busbar-thickness').addEventListener('change', (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
if (v > 0) busbarStore.setThickness(bb.id, v);
|
||||
|
||||
@@ -39,6 +39,10 @@ export const busbarStore = {
|
||||
color: b.color,
|
||||
cellIndices: Array.isArray(b.cellIndices) ? [...b.cellIndices] : [],
|
||||
thickness: b.thickness,
|
||||
overlapEnabled: b.overlapEnabled === true,
|
||||
overlapSize: Number.isFinite(Number(b.overlapSize)) && Number(b.overlapSize) > 0
|
||||
? Number(b.overlapSize)
|
||||
: 10,
|
||||
face: b.face === 'bottom' ? 'bottom' : 'top',
|
||||
})),
|
||||
};
|
||||
@@ -58,6 +62,10 @@ export const busbarStore = {
|
||||
thickness: Number.isFinite(Number(b.thickness)) && Number(b.thickness) > 0
|
||||
? Number(b.thickness)
|
||||
: 1.0,
|
||||
overlapEnabled: b.overlapEnabled === true,
|
||||
overlapSize: Number.isFinite(Number(b.overlapSize)) && Number(b.overlapSize) > 0
|
||||
? Number(b.overlapSize)
|
||||
: 10,
|
||||
face: b.face === 'bottom' ? 'bottom' : 'top',
|
||||
}));
|
||||
|
||||
@@ -92,6 +100,8 @@ export const busbarStore = {
|
||||
color,
|
||||
cellIndices: [],
|
||||
thickness: 1.0,
|
||||
overlapEnabled: false,
|
||||
overlapSize: 10,
|
||||
face: face === 'bottom' ? 'bottom' : 'top',
|
||||
};
|
||||
paletteIdx++;
|
||||
@@ -138,6 +148,24 @@ export const busbarStore = {
|
||||
}
|
||||
},
|
||||
|
||||
setOverlapEnabled(id, overlapEnabled) {
|
||||
const b = this.list.find(b => b.id === id);
|
||||
if (b) {
|
||||
b.overlapEnabled = overlapEnabled === true;
|
||||
this._emitMutation('setOverlapEnabled');
|
||||
this._notify();
|
||||
}
|
||||
},
|
||||
|
||||
setOverlapSize(id, overlapSize) {
|
||||
const b = this.list.find(b => b.id === id);
|
||||
if (b && Number.isFinite(Number(overlapSize)) && Number(overlapSize) > 0) {
|
||||
b.overlapSize = Number(overlapSize);
|
||||
this._emitMutation('setOverlapSize');
|
||||
this._notify();
|
||||
}
|
||||
},
|
||||
|
||||
setFace(id, face) {
|
||||
const b = this.list.find(b => b.id === id);
|
||||
if (b) {
|
||||
|
||||
@@ -148,21 +148,30 @@ function angleCovered(theta, dirs) {
|
||||
export function buildBusbarDXF(geometry, positions, padRadius) {
|
||||
const layer = 'busbar';
|
||||
const tokens = [...dxfHeader(), ...dxfTables(), '0', 'SECTION', '2', 'ENTITIES'];
|
||||
const extraPads = Array.isArray(geometry.extraPads) ? geometry.extraPads : [];
|
||||
const extraSegments = Array.isArray(geometry.extraSegments) ? geometry.extraSegments : [];
|
||||
const cutouts = Array.isArray(geometry.cutouts) ? geometry.cutouts : [];
|
||||
|
||||
// Enumerate pads (cells + waypoints) and capsule segments with shared keys so we
|
||||
// can tell which shapes are "self" vs "other" when clipping.
|
||||
const pads = new Map();
|
||||
const caps = []; // { a, b, padKeyA, padKeyB }
|
||||
const caps = []; // { a, b, padKeyA, padKeyB, radius }
|
||||
|
||||
const ensurePad = (key, pos) => {
|
||||
if (!pads.has(key)) pads.set(key, { pos, dirs: [] });
|
||||
const ensurePad = (key, pos, radius = padRadius) => {
|
||||
if (!pads.has(key)) pads.set(key, { pos, dirs: [], radius });
|
||||
else pads.get(key).radius = Math.max(pads.get(key).radius, radius);
|
||||
return pads.get(key);
|
||||
};
|
||||
|
||||
for (const idx of geometry.padIndices) {
|
||||
const p = positions[idx];
|
||||
if (!p) continue;
|
||||
ensurePad(`c${idx}`, p);
|
||||
ensurePad(`c${idx}`, p, padRadius);
|
||||
}
|
||||
|
||||
for (const pad of extraPads) {
|
||||
if (!pad?.key || !Array.isArray(pad.pos)) continue;
|
||||
ensurePad(pad.key, pad.pos, pad.radius ?? padRadius);
|
||||
}
|
||||
|
||||
geometry.edges.forEach((edge, ei) => {
|
||||
@@ -174,7 +183,7 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
|
||||
...edge.waypoints.map((wp, wi) => ({ key: `w${ei}_${wi}`, pos: wp })),
|
||||
{ key: `c${edge.to}`, pos: to },
|
||||
];
|
||||
for (let i = 1; i < stops.length - 1; i++) ensurePad(stops[i].key, stops[i].pos);
|
||||
for (let i = 1; i < stops.length - 1; i++) ensurePad(stops[i].key, stops[i].pos, padRadius);
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const a = stops[i], b = stops[i + 1];
|
||||
const dx = b.pos[0] - a.pos[0];
|
||||
@@ -182,19 +191,39 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < EPS) continue;
|
||||
const ang = Math.atan2(dy, dx);
|
||||
ensurePad(a.key, a.pos).dirs.push(ang);
|
||||
ensurePad(b.key, b.pos).dirs.push(ang + Math.PI);
|
||||
caps.push({ a: a.pos.slice(), b: b.pos.slice(), padKeyA: a.key, padKeyB: b.key });
|
||||
ensurePad(a.key, a.pos, padRadius).dirs.push(ang);
|
||||
ensurePad(b.key, b.pos, padRadius).dirs.push(ang + Math.PI);
|
||||
caps.push({ a: a.pos.slice(), b: b.pos.slice(), padKeyA: a.key, padKeyB: b.key, radius: padRadius });
|
||||
}
|
||||
});
|
||||
|
||||
extraSegments.forEach((segment, index) => {
|
||||
if (!Array.isArray(segment?.from) || !Array.isArray(segment?.to)) return;
|
||||
const fromKey = segment.fromKey || `extra_from_${index}`;
|
||||
const toKey = segment.toKey || `extra_to_${index}`;
|
||||
const radius = segment.radius ?? padRadius;
|
||||
ensurePad(fromKey, segment.from, radius);
|
||||
ensurePad(toKey, segment.to, radius);
|
||||
|
||||
const dx = segment.to[0] - segment.from[0];
|
||||
const dy = segment.to[1] - segment.from[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < EPS) return;
|
||||
|
||||
const ang = Math.atan2(dy, dx);
|
||||
ensurePad(fromKey, segment.from, radius).dirs.push(ang);
|
||||
ensurePad(toKey, segment.to, radius).dirs.push(ang + Math.PI);
|
||||
caps.push({ a: segment.from.slice(), b: segment.to.slice(), padKeyA: fromKey, padKeyB: toKey, radius });
|
||||
});
|
||||
|
||||
// Pad arcs: parts of each circle not covered by any connected capsule half-disc
|
||||
// AND not interior to any other capsule rectangle or pad circle.
|
||||
const padList = Array.from(pads.entries()).map(([key, data]) => ({ key, ...data }));
|
||||
for (const pad of padList) {
|
||||
const [cx, cy] = pad.pos;
|
||||
const localRadius = pad.radius ?? padRadius;
|
||||
if (pad.dirs.length === 0) {
|
||||
tokens.push(...circleEntity(cx, cy, padRadius, layer));
|
||||
tokens.push(...circleEntity(cx, cy, localRadius, layer));
|
||||
continue;
|
||||
}
|
||||
const tangents = [];
|
||||
@@ -217,21 +246,22 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
|
||||
// Also require the arc's midpoint to be outside all other shapes so it's
|
||||
// genuinely on the outline — an arc that enters a neighbour's capsule is
|
||||
// interior and must be dropped.
|
||||
const mx = cx + padRadius * Math.cos(mid);
|
||||
const my = cy + padRadius * Math.sin(mid);
|
||||
const mx = cx + localRadius * Math.cos(mid);
|
||||
const my = cy + localRadius * Math.sin(mid);
|
||||
let buried = false;
|
||||
for (const cap of caps) {
|
||||
if (cap.padKeyA === pad.key || cap.padKeyB === pad.key) continue;
|
||||
if (segInsideCapsule([mx, my], [mx, my], cap.a, cap.b, padRadius).length) { buried = true; break; }
|
||||
if (segInsideCapsule([mx, my], [mx, my], cap.a, cap.b, cap.radius ?? padRadius).length) { buried = true; break; }
|
||||
}
|
||||
if (!buried) {
|
||||
for (const other of padList) {
|
||||
if (other.key === pad.key) continue;
|
||||
const dx = mx - other.pos[0], dy = my - other.pos[1];
|
||||
if (dx * dx + dy * dy < padRadius * padRadius - EPS) { buried = true; break; }
|
||||
const otherRadius = other.radius ?? padRadius;
|
||||
if (dx * dx + dy * dy < otherRadius * otherRadius - EPS) { buried = true; break; }
|
||||
}
|
||||
}
|
||||
if (!buried) tokens.push(...arcEntity(cx, cy, padRadius, t1, t2, layer));
|
||||
if (!buried) tokens.push(...arcEntity(cx, cy, localRadius, t1, t2, layer));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,8 +274,9 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
|
||||
const dy = cap.b[1] - cap.a[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < EPS) continue;
|
||||
const nx = -dy / len * padRadius;
|
||||
const ny = dx / len * padRadius;
|
||||
const localRadius = cap.radius ?? padRadius;
|
||||
const nx = -dy / len * localRadius;
|
||||
const ny = dx / len * localRadius;
|
||||
const sides = [
|
||||
{ p: [cap.a[0] + nx, cap.a[1] + ny], q: [cap.b[0] + nx, cap.b[1] + ny] },
|
||||
{ p: [cap.a[0] - nx, cap.a[1] - ny], q: [cap.b[0] - nx, cap.b[1] - ny] },
|
||||
@@ -254,11 +285,11 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
|
||||
const insides = [];
|
||||
for (let j = 0; j < caps.length; j++) {
|
||||
if (j === ci) continue;
|
||||
insides.push(...segInsideCapsule(side.p, side.q, caps[j].a, caps[j].b, padRadius));
|
||||
insides.push(...segInsideCapsule(side.p, side.q, caps[j].a, caps[j].b, caps[j].radius ?? padRadius));
|
||||
}
|
||||
for (const pad of padList) {
|
||||
if (pad.key === cap.padKeyA || pad.key === cap.padKeyB) continue;
|
||||
insides.push(...segInsideCircle(side.p, side.q, pad.pos, padRadius));
|
||||
insides.push(...segInsideCircle(side.p, side.q, pad.pos, pad.radius ?? padRadius));
|
||||
}
|
||||
const outside = subtractIntervals(insides);
|
||||
for (const [t0, t1] of outside) {
|
||||
@@ -272,6 +303,20 @@ export function buildBusbarDXF(geometry, positions, padRadius) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const cutout of cutouts) {
|
||||
const [cx, cy] = cutout.center;
|
||||
const halfWidth = cutout.width / 2;
|
||||
const halfHeight = cutout.height / 2;
|
||||
const x1 = cx - halfWidth;
|
||||
const x2 = cx + halfWidth;
|
||||
const y1 = cy - halfHeight;
|
||||
const y2 = cy + halfHeight;
|
||||
tokens.push(...lineEntity(x1, y1, x2, y1, layer));
|
||||
tokens.push(...lineEntity(x2, y1, x2, y2, layer));
|
||||
tokens.push(...lineEntity(x2, y2, x1, y2, layer));
|
||||
tokens.push(...lineEntity(x1, y2, x1, y1, layer));
|
||||
}
|
||||
|
||||
tokens.push('0', 'ENDSEC', '0', 'EOF');
|
||||
return tokens.join('\n') + '\n';
|
||||
}
|
||||
|
||||
10
src/main.js
10
src/main.js
@@ -144,9 +144,11 @@ function applyConfigToUi(config) {
|
||||
setSelectInput('bmsHolesType', config.bms.type);
|
||||
setNumberInput('bmsHoleDiameter', config.bms.holeDiameter);
|
||||
setNumberInput('tabWidth', config.bms.tabWidth);
|
||||
setNumberInput('tabDepth', config.bms.tabDepth);
|
||||
setNumberInput('tabLength', config.bms.tabLength ?? 10.0);
|
||||
setSelectInput('tabOverlapSide', config.bms.tabOverlapSide || 'off');
|
||||
|
||||
setSelectInput('busbarFormat', config.busbars.format);
|
||||
setCheckboxInput('busbarCellCutoutEnabled', config.busbars.cellCutoutEnabled === true);
|
||||
setPackMode(config.pack.mode, { clearBusbars: false, refresh: false });
|
||||
|
||||
toggleBmsDiameter();
|
||||
@@ -222,8 +224,8 @@ function wireUrlSyncListeners() {
|
||||
const ids = [
|
||||
'series', 'parallel', 'xDim', 'yDim', 'height',
|
||||
'cellSize', 'layoutType', 'spacing', 'coverThickness', 'ledgeWidth',
|
||||
'roundedCorners', 'bmsHolesType', 'bmsHoleDiameter', 'tabWidth', 'tabDepth',
|
||||
'busbarFormat',
|
||||
'roundedCorners', 'bmsHolesType', 'bmsHoleDiameter', 'tabWidth',
|
||||
'tabOverlapSide', 'busbarFormat', 'busbarCellCutoutEnabled',
|
||||
];
|
||||
|
||||
ids.forEach((id) => {
|
||||
@@ -338,7 +340,7 @@ function wireInputs() {
|
||||
element.addEventListener('change', handler);
|
||||
});
|
||||
|
||||
const visualInputs = ['bmsHolesType', 'roundedCorners', 'bmsHoleDiameter', 'ledgeWidth', 'tabWidth', 'tabDepth'];
|
||||
const visualInputs = ['bmsHolesType', 'roundedCorners', 'bmsHoleDiameter', 'ledgeWidth', 'tabWidth', 'tabLength', 'tabOverlapSide', 'busbarCellCutoutEnabled'];
|
||||
visualInputs.forEach(id => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
|
||||
56
src/model.js
56
src/model.js
@@ -17,6 +17,8 @@ export function create3DModel(positions, config) {
|
||||
useTabs,
|
||||
useFullCircles,
|
||||
filletBms,
|
||||
tabOverlapSide,
|
||||
layoutType = 'honeycomb',
|
||||
} = config;
|
||||
|
||||
const r = cellSize / 2;
|
||||
@@ -212,16 +214,51 @@ export function create3DModel(positions, config) {
|
||||
const topRow = rows[topYKey].sort((a, b) => a[0] - b[0]);
|
||||
const bottomRow = rows[bottomYKey].sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const minAllX = Math.min(...positions.map(([x]) => x));
|
||||
|
||||
// Vertical column pitch: minimum X delta between any two cells
|
||||
const _allXSorted = [...new Set(positions.map(([x]) => Math.round(x * 1000)))]
|
||||
.sort((a, b) => a - b).map(v => v / 1000);
|
||||
const vertColPitch = _allXSorted.length >= 2 ? _allXSorted[1] - _allXSorted[0] : 0;
|
||||
|
||||
const topPitch = topRow.length >= 2 ? topRow[topRow.length - 1][0] - topRow[topRow.length - 2][0] : 0;
|
||||
const topHoles = [];
|
||||
for (let i = 0; i < topRow.length - 1; i++) {
|
||||
const xMid = (topRow[i][0] + topRow[i + 1][0]) / 2;
|
||||
topHoles.push([xMid, holeYTop]);
|
||||
topHoles.push([(topRow[i][0] + topRow[i + 1][0]) / 2, holeYTop]);
|
||||
}
|
||||
if (layoutType === 'vertical') {
|
||||
const topIsEven = vertColPitch === 0 || (topRow[0][0] - minAllX) < vertColPitch / 2;
|
||||
if (!topIsEven) {
|
||||
topHoles.unshift([topRow[0][0] - vertColPitch / 2, holeYTop]);
|
||||
topHoles.push([topRow[topRow.length - 1][0] + vertColPitch / 2, holeYTop]);
|
||||
}
|
||||
} else if (layoutType !== 'grid') {
|
||||
const topExtraRight = topRow.length >= 2 && (topRow[0][0] - minAllX) < topPitch / 4;
|
||||
if (topExtraRight) {
|
||||
topHoles.push([topRow[topRow.length - 1][0] + topPitch / 2, holeYTop]);
|
||||
} else {
|
||||
topHoles.unshift([topRow[0][0] - topPitch / 2, holeYTop]);
|
||||
}
|
||||
}
|
||||
|
||||
const bottomPitch = bottomRow.length >= 2 ? bottomRow[bottomRow.length - 1][0] - bottomRow[bottomRow.length - 2][0] : 0;
|
||||
const bottomHoles = [];
|
||||
for (let i = 0; i < bottomRow.length - 1; i++) {
|
||||
const xMid = (bottomRow[i][0] + bottomRow[i + 1][0]) / 2;
|
||||
bottomHoles.push([xMid, holeYBottom]);
|
||||
bottomHoles.push([(bottomRow[i][0] + bottomRow[i + 1][0]) / 2, holeYBottom]);
|
||||
}
|
||||
if (layoutType === 'vertical') {
|
||||
const bottomIsEven = vertColPitch === 0 || (bottomRow[0][0] - minAllX) < vertColPitch / 2;
|
||||
if (!bottomIsEven) {
|
||||
bottomHoles.unshift([bottomRow[0][0] - vertColPitch / 2, holeYBottom]);
|
||||
bottomHoles.push([bottomRow[bottomRow.length - 1][0] + vertColPitch / 2, holeYBottom]);
|
||||
}
|
||||
} else if (layoutType !== 'grid') {
|
||||
const bottomExtraRight = bottomRow.length >= 2 && (bottomRow[0][0] - minAllX) < bottomPitch / 4;
|
||||
if (bottomExtraRight) {
|
||||
bottomHoles.push([bottomRow[bottomRow.length - 1][0] + bottomPitch / 2, holeYBottom]);
|
||||
} else {
|
||||
bottomHoles.unshift([bottomRow[0][0] - bottomPitch / 2, holeYBottom]);
|
||||
}
|
||||
}
|
||||
|
||||
const allBmsHoles = [...topHoles, ...bottomHoles];
|
||||
@@ -229,25 +266,28 @@ export function create3DModel(positions, config) {
|
||||
if (useTabs) {
|
||||
const slotWidth = config.tabWidth || holeDiameter;
|
||||
const slotInset = config.tabDepth || 1.0;
|
||||
const slotHeight = Number.isFinite(config.tabLength) && config.tabLength > 0
|
||||
? Math.min(config.tabLength, height)
|
||||
: height;
|
||||
const topEdgeY = length / 2;
|
||||
const bottomEdgeY = -length / 2;
|
||||
|
||||
const allSlots = [];
|
||||
|
||||
topHoles.forEach(([xPos]) => {
|
||||
const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, height);
|
||||
const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, slotHeight);
|
||||
const slot = slotBox.Shape();
|
||||
const trans = new oc.gp_Trsf();
|
||||
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, topEdgeY - slotInset, 0));
|
||||
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, topEdgeY - slotInset, height - slotHeight));
|
||||
const slotTransform = new oc.BRepBuilderAPI_Transform(slot, trans, false);
|
||||
allSlots.push(slotTransform.Shape());
|
||||
});
|
||||
|
||||
bottomHoles.forEach(([xPos]) => {
|
||||
const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, height);
|
||||
const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, slotHeight);
|
||||
const slot = slotBox.Shape();
|
||||
const trans = new oc.gp_Trsf();
|
||||
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, bottomEdgeY, 0));
|
||||
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, bottomEdgeY, height - slotHeight));
|
||||
const slotTransform = new oc.BRepBuilderAPI_Transform(slot, trans, false);
|
||||
allSlots.push(slotTransform.Shape());
|
||||
});
|
||||
|
||||
@@ -75,6 +75,7 @@ export function drawPreview(positions, cellSize) {
|
||||
const useTabs = bmsHolesType === 'tabs';
|
||||
const useFullCircles = bmsHolesType === 'fullcircles';
|
||||
const circleHoleOffset = false;
|
||||
const layoutType = document.getElementById('layoutType').value;
|
||||
const roundedCorners = document.getElementById('roundedCorners').checked;
|
||||
const bmsHoleDiameter = parseFloat(document.getElementById('bmsHoleDiameter').value) || 4.0;
|
||||
const ledgeWidth = parseFloat(document.getElementById('ledgeWidth').value) || 0;
|
||||
@@ -238,11 +239,18 @@ export function drawPreview(positions, cellSize) {
|
||||
const holeTopRowKey = useFullCircles ? visualTopRowKey : topYKey;
|
||||
const holeBottomRowKey = useFullCircles ? visualBottomRowKey : bottomYKey;
|
||||
|
||||
for (let i = 0; i < rows[holeTopRowKey].length - 1; i++) {
|
||||
const x = (rows[holeTopRowKey][i][0] + rows[holeTopRowKey][i + 1][0]) / 2;
|
||||
const cellY = rows[holeTopRowKey][i][1];
|
||||
const x1 = rows[holeTopRowKey][i][0];
|
||||
const x2 = rows[holeTopRowKey][i + 1][0];
|
||||
// Vertical column pitch: minimum X delta between any two cells
|
||||
const _allXSorted = [...new Set(positions.map(([x]) => Math.round(x * 1000)))]
|
||||
.sort((a, b) => a - b).map(v => v / 1000);
|
||||
const vertColPitch = _allXSorted.length >= 2 ? _allXSorted[1] - _allXSorted[0] : 0;
|
||||
|
||||
const topTabRow = rows[holeTopRowKey];
|
||||
const topTabPitch = topTabRow.length >= 2 ? topTabRow[topTabRow.length - 1][0] - topTabRow[topTabRow.length - 2][0] : 0;
|
||||
for (let i = 0; i < topTabRow.length - 1; i++) {
|
||||
const x = (topTabRow[i][0] + topTabRow[i + 1][0]) / 2;
|
||||
const cellY = topTabRow[i][1];
|
||||
const x1 = topTabRow[i][0];
|
||||
const x2 = topTabRow[i + 1][0];
|
||||
const wallY = packMinY;
|
||||
const y = topEdge;
|
||||
const flip = cellY < wallY ? -1 : 1;
|
||||
@@ -261,12 +269,42 @@ export function drawPreview(positions, cellSize) {
|
||||
const debugTri = useFullCircles ? { apex: { x, y: wallY }, left, right } : null;
|
||||
bmsHolePositions.push({ x, y, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri });
|
||||
}
|
||||
if (layoutType === 'vertical') {
|
||||
if (!useFullCircles) {
|
||||
const topIsEven = vertColPitch === 0 || (topTabRow[0][0] - minX) < vertColPitch / 2;
|
||||
if (!topIsEven) {
|
||||
const topExtY = topEdge;
|
||||
// Left corner: continue interior hole pattern (tabPitch/2 = one colPitch to the left)
|
||||
bmsHolePositions.push({ x: topTabRow[0][0] - topTabPitch / 2, y: topExtY, diameter: bmsHoleDiameter, isTab: false, isFull: false, debugTri: null });
|
||||
// Right corner only if an even-col neighbor exists to the right
|
||||
const topRightNeighX = topTabRow[topTabRow.length - 1][0] + vertColPitch;
|
||||
if (_allXSorted.some(x => Math.abs(x - topRightNeighX) < 0.5)) {
|
||||
bmsHolePositions.push({ x: topTabRow[topTabRow.length - 1][0] + topTabPitch / 2, y: topExtY, diameter: bmsHoleDiameter, isTab: false, isFull: false, debugTri: null });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Full circles: top-right corner when an odd col exists beyond the last even col (even S)
|
||||
const topRightNeighX = topTabRow[topTabRow.length - 1][0] + vertColPitch;
|
||||
if (_allXSorted.some(x => Math.abs(x - topRightNeighX) < 0.5)) {
|
||||
// Continue the interior hole pattern: offset by topTabPitch/2 (= one colPitch)
|
||||
bmsHolePositions.push({ x: topTabRow[topTabRow.length - 1][0] + topTabPitch / 2, y: topEdge, diameter: bmsHoleDiameter, isTab: false, isFull: true, debugTri: null });
|
||||
}
|
||||
}
|
||||
} else if (layoutType !== 'grid') {
|
||||
const topTabExtraRight = topTabRow.length < 2 || (topTabRow[0][0] - minX) < topTabPitch / 4;
|
||||
bmsHolePositions.push(topTabExtraRight
|
||||
? { x: topTabRow[topTabRow.length - 1][0] + topTabPitch / 2, y: topEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null }
|
||||
: { x: topTabRow[0][0] - topTabPitch / 2, y: topEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
|
||||
}
|
||||
|
||||
const botTabRow = rows[holeBottomRowKey];
|
||||
const botTabPitch = botTabRow.length >= 2 ? botTabRow[botTabRow.length - 1][0] - botTabRow[botTabRow.length - 2][0] : 0;
|
||||
for (let i = 0; i < botTabRow.length - 1; i++) {
|
||||
const x = (botTabRow[i][0] + botTabRow[i + 1][0]) / 2;
|
||||
const cellY = botTabRow[i][1];
|
||||
const x1 = botTabRow[i][0];
|
||||
const x2 = botTabRow[i + 1][0];
|
||||
|
||||
for (let i = 0; i < rows[holeBottomRowKey].length - 1; i++) {
|
||||
const x = (rows[holeBottomRowKey][i][0] + rows[holeBottomRowKey][i + 1][0]) / 2;
|
||||
const cellY = rows[holeBottomRowKey][i][1];
|
||||
const x1 = rows[holeBottomRowKey][i][0];
|
||||
const x2 = rows[holeBottomRowKey][i + 1][0];
|
||||
const wallY = packMaxY;
|
||||
const y = bottomEdge;
|
||||
const flip = cellY < wallY ? -1 : 1;
|
||||
@@ -285,6 +323,31 @@ export function drawPreview(positions, cellSize) {
|
||||
const debugTri = useFullCircles ? { apex: { x, y: wallY }, left, right } : null;
|
||||
bmsHolePositions.push({ x, y, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri });
|
||||
}
|
||||
if (layoutType === 'vertical') {
|
||||
const botIsEven = vertColPitch === 0 || (botTabRow[0][0] - minX) < vertColPitch / 2;
|
||||
if (!botIsEven) {
|
||||
// Use bottomEdge for all types so corner aligns with interior bottom holes
|
||||
const botExtY = bottomEdge;
|
||||
// Left corner: continue interior hole pattern (botTabPitch/2 = one colPitch to the left)
|
||||
bmsHolePositions.push({ x: botTabRow[0][0] - botTabPitch / 2, y: botExtY, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
|
||||
// Right corner only if an even-col neighbor exists to the right (odd S = yes, even S = no)
|
||||
const botRightNeighX = botTabRow[botTabRow.length - 1][0] + vertColPitch;
|
||||
if (_allXSorted.some(x => Math.abs(x - botRightNeighX) < 0.5)) {
|
||||
bmsHolePositions.push({ x: botTabRow[botTabRow.length - 1][0] + botTabPitch / 2, y: botExtY, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
|
||||
}
|
||||
} else if (vertColPitch > 0 && botTabRow.length >= 2) {
|
||||
// Even-col row (visual top for non-FC): add right corner when an odd col extends beyond the last even col
|
||||
const botRightNeighX = botTabRow[botTabRow.length - 1][0] + vertColPitch;
|
||||
if (_allXSorted.some(x => Math.abs(x - botRightNeighX) < 0.5)) {
|
||||
bmsHolePositions.push({ x: botTabRow[botTabRow.length - 1][0] + botTabPitch / 2, y: bottomEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
|
||||
}
|
||||
}
|
||||
} else if (layoutType !== 'grid') {
|
||||
const botTabExtraRight = botTabRow.length < 2 || (botTabRow[0][0] - minX) < botTabPitch / 4;
|
||||
bmsHolePositions.push(botTabExtraRight
|
||||
? { x: botTabRow[botTabRow.length - 1][0] + botTabPitch / 2, y: bottomEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null }
|
||||
: { x: botTabRow[0][0] - botTabPitch / 2, y: bottomEdge, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri: null });
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#1e293b';
|
||||
@@ -321,20 +384,19 @@ export function drawPreview(positions, cellSize) {
|
||||
ctx.strokeStyle = 'rgba(255, 193, 7, 0.9)';
|
||||
ctx.lineWidth = 1.5 / zoom;
|
||||
|
||||
const tabWidthMm = parseFloat(document.getElementById('tabWidth').value) || 4.0;
|
||||
const tabDepthMm = parseFloat(document.getElementById('tabDepth').value) || 1.0;
|
||||
const tabWidthMm = parseFloat(document.getElementById('height').value) || 10.0;
|
||||
const tabWidth = tabWidthMm * scale;
|
||||
const tabHeight = tabDepthMm * scale;
|
||||
const tabHeight = 1.0 * scale;
|
||||
|
||||
for (const hole of bmsHolePositions) {
|
||||
const cx = (hole.x - minX + r + spacing) * scale + offsetX;
|
||||
|
||||
if (hole.y > maxY) {
|
||||
ctx.fillRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
|
||||
ctx.strokeRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
|
||||
} else {
|
||||
if (hole.y < minY) {
|
||||
ctx.fillRect(cx - tabWidth / 2, offsetY, tabWidth, tabHeight);
|
||||
ctx.strokeRect(cx - tabWidth / 2, offsetY, tabWidth, tabHeight);
|
||||
} else {
|
||||
ctx.fillRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
|
||||
ctx.strokeRect(cx - tabWidth / 2, offsetY + packHeight * scale - tabHeight, tabWidth, tabHeight);
|
||||
}
|
||||
}
|
||||
} else if (useFullCircles) {
|
||||
|
||||
@@ -33,9 +33,11 @@ export function toggleBmsDiameter() {
|
||||
const bmsType = document.getElementById('bmsHolesType').value;
|
||||
const diameterGroup = document.getElementById('bmsHoleDiameterGroup');
|
||||
const tabDimsGroup = document.getElementById('tabDimensionsGroup');
|
||||
const tabOverlapSideGroup = document.getElementById('tabOverlapSideGroup');
|
||||
diameterGroup.style.display =
|
||||
(bmsType === 'halfcircles' || bmsType === 'fullcircles') ? 'block' : 'none';
|
||||
tabDimsGroup.style.display = (bmsType === 'tabs') ? 'grid' : 'none';
|
||||
if (tabOverlapSideGroup) tabOverlapSideGroup.style.display = (bmsType === 'tabs') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
export function initCustomSelects() {
|
||||
|
||||
@@ -3,6 +3,13 @@ const HASH_PREFIX = '#config=';
|
||||
|
||||
const ALLOWED_LAYOUTS = new Set(['grid', 'honeycomb', 'vertical']);
|
||||
const ALLOWED_BMS_TYPES = new Set(['off', 'halfcircles', 'fullcircles', 'tabs']);
|
||||
const ALLOWED_TAB_OVERLAP_SIDES = new Set(['off', 'top', 'bottom', 'left', 'right']);
|
||||
|
||||
function normalizeTabOverlapSide(value) {
|
||||
if (value === 'top' || value === 'bottom' || value === 'off') return value;
|
||||
if (value === 'left' || value === 'right') return 'off';
|
||||
throw new Error('Invalid tab overlap side');
|
||||
}
|
||||
const ALLOWED_PACK_MODES = new Set(['sp', 'mm']);
|
||||
const ALLOWED_BUSBAR_FORMATS = new Set(['step', 'dxf']);
|
||||
|
||||
@@ -58,8 +65,12 @@ function normalizeBusbar(raw, index) {
|
||||
});
|
||||
|
||||
const face = raw.face === 'bottom' ? 'bottom' : 'top';
|
||||
|
||||
return { id, name, color, thickness, cellIndices, face };
|
||||
const overlapEnabled = raw.overlapEnabled == null ? false : requireBoolean(raw.overlapEnabled, `busbars.list[${index}].overlapEnabled`);
|
||||
const overlapSize = raw.overlapSize == null
|
||||
? 10
|
||||
: requireFiniteNumber(Number(raw.overlapSize), `busbars.list[${index}].overlapSize`);
|
||||
if (overlapSize <= 0) throw new Error(`Invalid overlap size at index ${index}`);
|
||||
return { id, name, color, thickness, cellIndices, face, overlapEnabled, overlapSize };
|
||||
}
|
||||
|
||||
function normalizeConfig(raw) {
|
||||
@@ -86,9 +97,15 @@ function normalizeConfig(raw) {
|
||||
|
||||
const bmsType = requireString(bms.type, 'bms.type');
|
||||
if (!ALLOWED_BMS_TYPES.has(bmsType)) throw new Error('Invalid BMS type');
|
||||
const rawTabOverlapSide = requireString(bms.tabOverlapSide ?? 'off', 'bms.tabOverlapSide');
|
||||
if (!ALLOWED_TAB_OVERLAP_SIDES.has(rawTabOverlapSide)) throw new Error('Invalid tab overlap side');
|
||||
const tabOverlapSide = normalizeTabOverlapSide(rawTabOverlapSide);
|
||||
|
||||
const busbarFormat = requireString(busbars.format, 'busbars.format');
|
||||
if (!ALLOWED_BUSBAR_FORMATS.has(busbarFormat)) throw new Error('Invalid busbar format');
|
||||
const cellCutoutEnabled = busbars.cellCutoutEnabled == null
|
||||
? false
|
||||
: requireBoolean(busbars.cellCutoutEnabled, 'busbars.cellCutoutEnabled');
|
||||
|
||||
const list = Array.isArray(busbars.list)
|
||||
? busbars.list.map((bb, i) => normalizeBusbar(bb, i))
|
||||
@@ -122,11 +139,14 @@ function normalizeConfig(raw) {
|
||||
type: bmsType,
|
||||
holeDiameter: requireFiniteNumber(Number(bms.holeDiameter), 'bms.holeDiameter'),
|
||||
tabWidth: requireFiniteNumber(Number(bms.tabWidth), 'bms.tabWidth'),
|
||||
tabLength: requireFiniteNumber(Number(bms.tabLength ?? 10), 'bms.tabLength'),
|
||||
tabDepth: requireFiniteNumber(Number(bms.tabDepth), 'bms.tabDepth'),
|
||||
tabOverlapSide,
|
||||
},
|
||||
busbars: {
|
||||
format: busbarFormat,
|
||||
activeId,
|
||||
cellCutoutEnabled,
|
||||
list,
|
||||
},
|
||||
};
|
||||
@@ -187,11 +207,14 @@ export function captureConfig(getPackMode, busbarSnapshot) {
|
||||
type: readString('bmsHolesType', 'fullcircles'),
|
||||
holeDiameter: readNumber('bmsHoleDiameter', 4.0),
|
||||
tabWidth: readNumber('tabWidth', 4.0),
|
||||
tabLength: readNumber('tabLength', 10.0),
|
||||
tabDepth: readNumber('tabDepth', 1.0),
|
||||
tabOverlapSide: normalizeTabOverlapSide(readString('tabOverlapSide', 'off')),
|
||||
},
|
||||
busbars: {
|
||||
format: readString('busbarFormat', 'step'),
|
||||
activeId: busbarSnapshot?.activeId ?? null,
|
||||
cellCutoutEnabled: readBool('busbarCellCutoutEnabled', false),
|
||||
list: Array.isArray(busbarSnapshot?.list)
|
||||
? busbarSnapshot.list.map(bb => ({ ...bb, face: bb.face === 'bottom' ? 'bottom' : 'top' }))
|
||||
: [],
|
||||
|
||||
@@ -1260,12 +1260,40 @@ input[type="checkbox"]:focus {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.busbar-swatch {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
.busbar-color-wrap {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.busbar-color {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.busbar-color::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.busbar-color::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.busbar-color::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.busbar-name {
|
||||
@@ -1310,6 +1338,7 @@ input[type="checkbox"]:focus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding-left: 24px;
|
||||
@@ -1317,6 +1346,47 @@ input[type="checkbox"]:focus {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.busbar-overlap-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.busbar-overlap {
|
||||
accent-color: #6495ed;
|
||||
}
|
||||
|
||||
.busbar-overlap-size-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #94a3b8;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.busbar-overlap-size {
|
||||
width: 64px;
|
||||
padding: 3px 6px;
|
||||
font-size: 0.9em;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(100, 149, 237, 0.2);
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.busbar-overlap-size:focus {
|
||||
outline: none;
|
||||
border-color: rgba(100, 149, 237, 0.6);
|
||||
}
|
||||
|
||||
.busbar-overlap-size:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.busbar-thickness-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user