Initial commit: cell holder generator + busbar builder

- 3D printable cell holder with STEP export (grid/honeycomb/vertical layouts)
- Configurable BMS holes (half circles, full circles, edge tabs)
- Busbar builder: click cells to define busbars, auto-routed with bend fallback
- Compound STEP export bundles holder + busbars
- OpenCascade.js (wasm) vendored for 3D modeling
This commit is contained in:
Maxim
2026-04-22 16:15:28 +02:00
commit ea030930cc
18 changed files with 3374 additions and 0 deletions

126
index.html Normal file
View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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 - waak.me</title>
<link rel="stylesheet" href="styles/main.css">
</head>
<body>
<div class="loading-overlay active" id="loadingOverlay" style="display: flex !important;">
<div class="loading-content">
<div class="spinner"></div>
<div class="loading-text" id="loadingText">Initializing 3D Engine</div>
<div class="loading-subtext" id="loadingSubtext">Loading OpenCascade...</div>
</div>
</div>
<div class="container">
<h1>Cell Holder Generator</h1>
<p class="subtitle">Generate custom 3D printable cell holders with STEP export</p>
<div class="main-layout">
<div class="config-sidebar">
<div class="section">
<h2>Configuration</h2>
<h3>Pack Dimensions</h3>
<div class="row">
<div class="form-group">
<label>Width (mm)</label>
<input type="number" id="xDim" value="150">
</div>
<div class="form-group">
<label>Length (mm)</label>
<input type="number" id="yDim" value="100">
</div>
</div>
<div class="form-group">
<label>Height (mm)</label>
<input type="number" id="height" value="10">
</div>
<h3>Cell Configuration</h3>
<div class="form-group">
<label>Cell Diameter (mm)</label>
<input type="number" id="cellSize" value="21.35">
</div>
<div class="form-group">
<label>Layout Type</label>
<select id="layoutType">
<option value="grid">Grid Layout</option>
<option value="honeycomb" selected>Honeycomb Layout</option>
<option value="vertical">Vertical Honeycomb</option>
</select>
</div>
<div class="form-group">
<label>Cell Spacing (mm)</label>
<input type="number" id="spacing" value="0.6">
</div>
<div class="form-group">
<label>Ledge Thickness (mm)</label>
<input type="number" id="coverThickness" value="0.4">
</div>
<div class="form-group">
<label>Ledge Width (mm)</label>
<input type="number" id="ledgeWidth" value="2.75">
</div>
<h3>Features</h3>
<div class="checkbox-group">
<input type="checkbox" id="roundedCorners" checked>
<label for="roundedCorners">Rounded Corners</label>
</div>
<div class="form-group">
<label>BMS Holes</label>
<select id="bmsHolesType">
<option value="off">Off</option>
<option value="halfcircles">Half Circles</option>
<option value="fullcircles" selected>Full Circles</option>
<option value="tabs">Edge Tabs</option>
</select>
</div>
<div class="form-group" id="bmsHoleDiameterGroup">
<label>BMS Hole Diameter (mm)</label>
<input type="number" id="bmsHoleDiameter" value="4.0" min="1" max="10">
</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">
</div>
</div>
<h3>Busbars</h3>
<div id="busbarList"></div>
<button class="btn-secondary" id="addBusbarBtn">+ Add Busbar</button>
<button class="btn" id="generateBtn">Generate 3D Model</button>
</div>
</div>
<div class="preview-container">
<h2>Preview</h2>
<canvas id="preview"></canvas>
<div id="previewStats">Configure settings and click Generate to see preview</div>
</div>
</div>
</div>
<script src="vendor/opencascade.wasm.js"></script>
<script type="module" src="src/main.js"></script>
</body>
</html>

495
src/app.js Normal file
View File

@@ -0,0 +1,495 @@
import { canvasState } from './state.js';
import { showStatus, showLoading } from './ui.js';
import { ocRef, initOC } from './oc.js';
import {
generateGridLayout,
generateHoneycombLayout,
generateVerticalHoneycombLayout,
getCachedPositions,
} from './layouts.js';
import { drawPreview, clearCanvas } from './preview.js';
import { create3DModel } from './model.js';
import { downloadSTEP } from './step-export.js';
import { busbarStore } from './busbars.js';
import { computeBusbarGeometry } from './busbar-geometry.js';
import { drawBusbarsOverlay } from './busbar-preview.js';
import { build3DBusbar } from './busbar-model.js';
import { renderBusbarList } from './busbar-ui.js';
let lastComputedGeometries = [];
export function getLastBusbarGeometries() {
return lastComputedGeometries;
}
export function updatePreview(resetView = false) {
if (resetView) {
canvasState.zoom = 1.0;
canvasState.panX = 0;
canvasState.panY = 0;
}
const stats = document.getElementById('previewStats');
const setStats = (text, color) => {
stats.textContent = text;
stats.style.color = color;
};
try {
const xDim = parseFloat(document.getElementById('xDim').value);
const yDim = parseFloat(document.getElementById('yDim').value);
const spacing = parseFloat(document.getElementById('spacing').value);
const cellSize = parseFloat(document.getElementById('cellSize').value);
const layoutType = document.getElementById('layoutType').value;
const ledgeWidth = parseFloat(document.getElementById('ledgeWidth').value) || 0;
const bmsHoleDiameter = parseFloat(document.getElementById('bmsHoleDiameter').value) || 4.0;
const coverThickness = parseFloat(document.getElementById('coverThickness').value);
if (!xDim || !yDim || !spacing || !cellSize) {
setStats('Configure settings to see preview', '#94a3b8');
return;
}
if (ledgeWidth > 0 && ledgeWidth >= cellSize) {
setStats(`Ledge width (${ledgeWidth}mm) must be less than cell diameter (${cellSize}mm)!`, '#ef4444');
clearCanvas();
return;
}
const minPackSize = cellSize + spacing * 2;
if (xDim < minPackSize || yDim < minPackSize) {
setStats(`Pack too small! Minimum: ${minPackSize.toFixed(1)}×${minPackSize.toFixed(1)} mm`, '#ef4444');
clearCanvas();
return;
}
if (cellSize > xDim || cellSize > yDim) {
setStats(`Cell diameter (${cellSize}mm) larger than pack dimensions!`, '#ef4444');
clearCanvas();
return;
}
if (spacing < 0) {
setStats(`Cell spacing cannot be negative!`, '#ef4444');
clearCanvas();
return;
}
const positions = getCachedPositions(xDim, yDim, spacing, cellSize, layoutType);
if (!positions || positions.length === 0) {
setStats(`No cells fit! Increase pack size or decrease cell size/spacing`, '#ef4444');
clearCanvas();
return;
}
const bmsHolesType = document.getElementById('bmsHolesType').value;
const bmsHoles = bmsHolesType !== 'off';
if (bmsHoles) {
if (bmsHoleDiameter > cellSize) {
setStats(`BMS hole (${bmsHoleDiameter}mm) larger than cell (${cellSize}mm)! Reduce hole size.`, '#ef4444');
clearCanvas();
return;
}
const cellRadius = cellSize / 2;
const bmsHoleRadius = bmsHoleDiameter / 2;
const r = cellRadius;
const minY = Math.min(...positions.map(p => p[1]));
const maxY = Math.max(...positions.map(p => p[1]));
const packMaxY = maxY + r + spacing;
const packMinY = minY - r - spacing;
const rows = {};
for (const [x, y] of positions) {
const key = Math.round(y * 1000);
if (!rows[key]) rows[key] = [];
rows[key].push([x, y]);
}
const rowKeys = Object.keys(rows).map(Number).sort((a, b) => a - b);
const topYKey = rowKeys[rowKeys.length - 1];
const bottomYKey = rowKeys[0];
rows[topYKey].sort((a, b) => a[0] - b[0]);
rows[bottomYKey].sort((a, b) => a[0] - b[0]);
const topEdge = packMaxY;
const bottomEdge = packMinY;
for (let i = 0; i < rows[topYKey].length - 1; i++) {
const bmsX = (rows[topYKey][i][0] + rows[topYKey][i + 1][0]) / 2;
const bmsY = topEdge;
for (const [cellX, cellY] of positions) {
const distance = Math.hypot(bmsX - cellX, bmsY - cellY);
const minDistance = bmsHoleRadius + cellRadius;
if (distance < minDistance) {
const maxDiameter = (distance - cellRadius) * 2;
setStats(`BMS hole overlaps cells! Max diameter: ${maxDiameter.toFixed(1)}mm`, '#ef4444');
canvasState.currentPositions = [];
clearCanvas();
return;
}
}
}
for (let i = 0; i < rows[bottomYKey].length - 1; i++) {
const bmsX = (rows[bottomYKey][i][0] + rows[bottomYKey][i + 1][0]) / 2;
const bmsY = bottomEdge;
for (const [cellX, cellY] of positions) {
const distance = Math.hypot(bmsX - cellX, bmsY - cellY);
const minDistance = bmsHoleRadius + cellRadius;
if (distance < minDistance) {
setStats(`BMS hole overlaps cells! Reduce hole diameter.`, '#ef4444');
canvasState.currentPositions = [];
clearCanvas();
return;
}
}
}
}
if (coverThickness > cellSize / 2) {
setStats(`Cover thickness (${coverThickness}mm) very large for cell size (${cellSize}mm)`, '#f59e0b');
}
if (spacing < 0.5 && spacing > 0) {
setStats(`Spacing < 0.5mm may be difficult to 3D print`, '#f59e0b');
}
if (positions.length < 2) {
setStats(`Only ${positions.length} cell fits. Increase pack size for practical holder.`, '#f59e0b');
} else {
stats.style.color = '#10b981';
}
drawPreview(positions, cellSize);
const cellRadius = cellSize / 2;
const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0);
const busbarKeepoutRadius = 4.0;
lastComputedGeometries = busbarStore.list.map(bb =>
computeBusbarGeometry(bb.cellIndices, positions, cellRadius, busbarPadRadius, spacing, busbarKeepoutRadius)
);
drawBusbarsOverlay(busbarStore.list, lastComputedGeometries, positions, cellSize, busbarPadRadius, spacing, busbarStore.activeId);
const blockedByBusbarId = {};
busbarStore.list.forEach((bb, i) => {
const g = lastComputedGeometries[i];
if (g && g.blocked) blockedByBusbarId[bb.id] = g.blocked.reason;
});
renderBusbarList(blockedByBusbarId);
const minX = Math.min(...positions.map(p => p[0]));
const minY = Math.min(...positions.map(p => p[1]));
const maxX = Math.max(...positions.map(p => p[0]));
const maxY = Math.max(...positions.map(p => p[1]));
const actualWidth = maxX - minX + cellSize + spacing * 2;
const actualHeight = maxY - minY + cellSize + spacing * 2;
if (positions.length >= 2) {
stats.textContent = `${positions.length} cells • ${actualWidth.toFixed(0)}×${actualHeight.toFixed(0)} mm`;
}
} catch (error) {
console.error('Preview error:', error);
setStats('Error: ' + error.message, '#ef4444');
}
}
export async function generateLayout() {
const layoutType = document.getElementById('layoutType').value;
if (!ocRef.initialized) {
showStatus('3D engine not ready. Please wait...', 'error');
await initOC();
if (!ocRef.initialized) return;
}
showLoading(true, 'Generating 3D Model', 'Please be patient...');
await new Promise(resolve => setTimeout(resolve, 50));
try {
const xDim = parseFloat(document.getElementById('xDim').value);
const yDim = parseFloat(document.getElementById('yDim').value);
const spacing = parseFloat(document.getElementById('spacing').value);
const cellSize = parseFloat(document.getElementById('cellSize').value);
const ledgeWidth = parseFloat(document.getElementById('ledgeWidth').value) || 0;
const bmsHoleDiameter = parseFloat(document.getElementById('bmsHoleDiameter').value) || 4.0;
const coverThickness = parseFloat(document.getElementById('coverThickness').value);
const cellRadius = cellSize / 2;
const bmsHoleRadius = bmsHoleDiameter / 2;
if (ledgeWidth > 0 && ledgeWidth >= cellSize) {
showStatus(`Ledge width (${ledgeWidth}mm) must be less than cell diameter (${cellSize}mm)!`, 'error');
showLoading(false);
return;
}
const minPackSize = cellSize + spacing * 2;
if (xDim < minPackSize || yDim < minPackSize) {
showStatus(`Pack too small! Minimum size: ${minPackSize.toFixed(1)}×${minPackSize.toFixed(1)} mm`, 'error');
showLoading(false);
return;
}
if (cellSize > xDim || cellSize > yDim) {
showStatus('Cell diameter is larger than pack dimensions!', 'error');
showLoading(false);
return;
}
if (spacing < 0) {
showStatus('Cell spacing cannot be negative!', 'error');
showLoading(false);
return;
}
const height = parseFloat(document.getElementById('height').value);
const terminalDiameter = 8.0;
const terminalDepth = 1.0;
const roundedCorners = document.getElementById('roundedCorners').checked;
const bmsHolesType = document.getElementById('bmsHolesType').value;
const bmsHoles = bmsHolesType !== 'off';
const useTabs = bmsHolesType === 'tabs';
const useFullCircles = bmsHolesType === 'fullcircles';
const filletBms = false;
const circleHoleOffset = false;
let positions;
let layoutName;
switch (layoutType) {
case 'grid':
positions = generateGridLayout(xDim, yDim, spacing, cellSize);
layoutName = 'Grid Layout';
break;
case 'honeycomb':
positions = generateHoneycombLayout(xDim, yDim, spacing, cellSize);
layoutName = 'Honeycomb Layout';
break;
case 'vertical':
positions = generateVerticalHoneycombLayout(xDim, yDim, spacing, cellSize);
layoutName = 'Vertical Honeycomb';
break;
default:
showStatus('Invalid layout type', 'error');
return;
}
if (bmsHoles && useFullCircles) {
const solveEquilateralY = (wallY, cellY, x1, x2) => {
const xMid = (x1 + x2) / 2;
const flip = cellY < wallY ? -1 : 1;
let lo = flip > 0 ? -Math.PI / 2 : 0, hi = flip > 0 ? 0 : Math.PI / 2;
for (let i = 0; i < 80; i++) {
const alpha = (lo + hi) / 2;
const d = xMid - (x1 + cellRadius * Math.cos(alpha));
const h = (cellY + cellRadius * Math.sin(alpha) - wallY) * flip;
const diff = h - d * Math.sqrt(3);
if (Math.abs(diff) < 1e-8) break;
if (diff < 0) { flip > 0 ? (lo = alpha) : (hi = alpha); } else { flip > 0 ? (hi = alpha) : (lo = alpha); }
}
const alpha = (lo + hi) / 2;
const By = cellY + cellRadius * Math.sin(alpha);
return (wallY + 2 * By) / 3;
};
const minY = Math.min(...positions.map(p => p[1]));
const maxY = Math.max(...positions.map(p => p[1]));
const packMinY = minY - cellRadius - spacing;
const packMaxY = maxY + cellRadius + spacing;
const rows = {};
for (const [x, y] of positions) {
const key = Math.round(y * 1000);
if (!rows[key]) rows[key] = [];
rows[key].push([x, y]);
}
const rowKeys = Object.keys(rows).map(Number).sort((a, b) => a - b);
const visualTopKey = rowKeys[0];
const visualBotKey = rowKeys[rowKeys.length - 1];
rows[visualTopKey].sort((a, b) => a[0] - b[0]);
rows[visualBotKey].sort((a, b) => a[0] - b[0]);
const holePositions = [];
for (let i = 0; i < rows[visualTopKey].length - 1; i++) {
const x1 = rows[visualTopKey][i][0], x2 = rows[visualTopKey][i + 1][0];
const adjY = rows[visualTopKey][i][1];
holePositions.push({ hx: (x1 + x2) / 2, hy: solveEquilateralY(packMinY, adjY, x1, x2) });
}
for (let i = 0; i < rows[visualBotKey].length - 1; i++) {
const x1 = rows[visualBotKey][i][0], x2 = rows[visualBotKey][i + 1][0];
const adjY = rows[visualBotKey][i][1];
holePositions.push({ hx: (x1 + x2) / 2, hy: solveEquilateralY(packMaxY, adjY, x1, x2) });
}
for (const { hx, hy } of holePositions) {
let minDist = Infinity;
for (const [cx, cy] of positions) {
const dist = Math.hypot(hx - cx, hy - cy);
if (dist < minDist) minDist = dist;
}
if (minDist < bmsHoleRadius + cellRadius) {
const maxAllowed = (minDist - cellRadius) * 2;
showStatus(`BMS hole too large, overlaps cell! Max diameter: ${maxAllowed.toFixed(2)}mm`, 'error');
showLoading(false);
return;
}
}
} else if (bmsHoles) {
const minY = Math.min(...positions.map(p => p[1]));
const maxY = Math.max(...positions.map(p => p[1]));
const r = cellSize / 2;
const packMinY = minY - r - spacing;
const packMaxY = maxY + r + spacing;
const rows = {};
for (const [x, y] of positions) {
const key = Math.round(y * 1000);
if (!rows[key]) rows[key] = [];
rows[key].push([x, y]);
}
const rowKeys = Object.keys(rows).map(Number).sort((a, b) => a - b);
const topYKey = rowKeys[rowKeys.length - 1];
const bottomYKey = rowKeys[0];
rows[topYKey].sort((a, b) => a[0] - b[0]);
rows[bottomYKey].sort((a, b) => a[0] - b[0]);
const topY = rows[topYKey][0][1];
const bottomY = rows[bottomYKey][0][1];
let topEdge, bottomEdge;
if (circleHoleOffset) {
const offsetDistance = bmsHoleRadius + 1.0;
topEdge = topY + cellRadius + offsetDistance;
bottomEdge = bottomY - cellRadius - offsetDistance;
} else {
topEdge = packMaxY;
bottomEdge = packMinY;
}
for (let i = 0; i < rows[topYKey].length - 1; i++) {
const bmsX = (rows[topYKey][i][0] + rows[topYKey][i + 1][0]) / 2;
const bmsY = topEdge;
const adjacentCell1 = [rows[topYKey][i][0], rows[topYKey][i][1]];
const adjacentCell2 = [rows[topYKey][i + 1][0], rows[topYKey][i + 1][1]];
for (const [cellX, cellY] of positions) {
if ((cellX === adjacentCell1[0] && cellY === adjacentCell1[1]) ||
(cellX === adjacentCell2[0] && cellY === adjacentCell2[1])) continue;
const distance = Math.hypot(bmsX - cellX, bmsY - cellY);
if (distance < bmsHoleRadius + cellRadius + ledgeWidth) {
showStatus(`BMS hole (${bmsHoleDiameter}mm) collides with cell ledges! Reduce BMS hole size or increase spacing.`, 'error');
showLoading(false);
return;
}
}
}
for (let i = 0; i < rows[bottomYKey].length - 1; i++) {
const bmsX = (rows[bottomYKey][i][0] + rows[bottomYKey][i + 1][0]) / 2;
const bmsY = bottomEdge;
const adjacentCell1 = [rows[bottomYKey][i][0], rows[bottomYKey][i][1]];
const adjacentCell2 = [rows[bottomYKey][i + 1][0], rows[bottomYKey][i + 1][1]];
for (const [cellX, cellY] of positions) {
if ((cellX === adjacentCell1[0] && cellY === adjacentCell1[1]) ||
(cellX === adjacentCell2[0] && cellY === adjacentCell2[1])) continue;
const distance = Math.hypot(bmsX - cellX, bmsY - cellY);
if (distance < bmsHoleRadius + cellRadius + ledgeWidth) {
showStatus(`BMS hole (${bmsHoleDiameter}mm) collides with cell ledges! Reduce BMS hole size or increase spacing.`, 'error');
showLoading(false);
return;
}
}
}
}
const tabWidth = parseFloat(document.getElementById('tabWidth').value) || 4.0;
const tabDepth = parseFloat(document.getElementById('tabDepth').value) || 1.0;
const config = {
cellSize, spacing, height, terminalDiameter, terminalDepth,
coverThickness, roundedCorners, bmsHoles, ledgeWidth,
filletBms, circleHoleOffset, useTabs, useFullCircles, bmsHoleDiameter,
tabWidth, tabDepth,
};
const holderShape = create3DModel(positions, config);
if (!holderShape) {
showStatus('Failed to create 3D model', 'error');
return;
}
const busbarPadRadius = Math.max(cellRadius - ledgeWidth, 1.0);
const busbarKeepoutRadius = terminalDiameter / 2;
const busbarGeometries = busbarStore.list.map(bb =>
computeBusbarGeometry(bb.cellIndices, positions, cellRadius, busbarPadRadius, spacing, busbarKeepoutRadius)
);
for (let i = 0; i < busbarStore.list.length; i++) {
const bb = busbarStore.list[i];
const geom = busbarGeometries[i];
if (geom.blocked) {
showStatus(`${bb.name}: ${geom.blocked.reason} — cannot export`, 'error');
showLoading(false);
return;
}
}
const holderCenterX = (Math.min(...positions.map(p => p[0])) + Math.max(...positions.map(p => p[0]))) / 2;
const holderCenterY = (Math.min(...positions.map(p => p[1])) + Math.max(...positions.map(p => p[1]))) / 2;
const centeredPositions = positions.map(([x, y]) => [x - holderCenterX, y - holderCenterY]);
const busbarShapes = [];
for (let i = 0; i < busbarStore.list.length; i++) {
const bb = busbarStore.list[i];
if (bb.cellIndices.length === 0) continue;
const shape = build3DBusbar(busbarGeometries[i], centeredPositions, busbarPadRadius, height, bb.thickness);
if (shape) busbarShapes.push(shape);
}
const oc = ocRef.instance;
let exportShape = holderShape;
if (busbarShapes.length > 0) {
const compound = new oc.TopoDS_Compound();
const builder = new oc.BRep_Builder();
builder.MakeCompound(compound);
builder.Add(compound, holderShape);
for (const s of busbarShapes) builder.Add(compound, s);
exportShape = compound;
}
const filename = `${layoutType}_layout.step`;
downloadSTEP(exportShape, filename);
const holeType = useTabs ? 'edge tabs' :
(circleHoleOffset ? 'circle offset' : 'semicircle offset');
const filletMsg = (filletBms && !useTabs) ? ' with filleted holes' : '';
const busbarMsg = busbarShapes.length > 0 ? ` + ${busbarShapes.length} busbar${busbarShapes.length === 1 ? '' : 's'}` : '';
showStatus(
`${layoutName} generated with ${positions.length} cells (${holeType}${filletMsg})${busbarMsg}`,
'success'
);
} catch (error) {
console.error('Generation error:', error);
showStatus('Error: ' + error.message, 'error');
} finally {
showLoading(false);
}
}

130
src/busbar-geometry.js Normal file
View File

@@ -0,0 +1,130 @@
function buildEdgePairs(indices, positions, cellRadius, spacing) {
const edges = [];
const threshold = (2 * cellRadius + spacing) * 1.3;
for (let i = 0; i < indices.length; i++) {
for (let j = i + 1; j < indices.length; j++) {
const a = positions[indices[i]], b = positions[indices[j]];
if (Math.hypot(a[0] - b[0], a[1] - b[1]) <= threshold) {
edges.push([indices[i], indices[j]]);
}
}
}
const adj = new Map();
indices.forEach(i => adj.set(i, []));
for (const [a, b] of edges) {
adj.get(a).push(b);
adj.get(b).push(a);
}
const visited = new Set();
const components = [];
for (const start of indices) {
if (visited.has(start)) continue;
const comp = [];
const stack = [start];
while (stack.length) {
const n = stack.pop();
if (visited.has(n)) continue;
visited.add(n);
comp.push(n);
for (const m of adj.get(n)) stack.push(m);
}
components.push(comp);
}
while (components.length > 1) {
let best = null, bestD = Infinity, bestI = -1, bestJ = -1;
for (let i = 0; i < components.length; i++) {
for (let j = i + 1; j < components.length; j++) {
for (const a of components[i]) {
for (const b of components[j]) {
const d = Math.hypot(positions[a][0] - positions[b][0], positions[a][1] - positions[b][1]);
if (d < bestD) { bestD = d; best = [a, b]; bestI = i; bestJ = j; }
}
}
}
}
edges.push(best);
components[bestI] = components[bestI].concat(components[bestJ]);
components.splice(bestJ, 1);
}
return edges;
}
function distPointToSegment(P, A, B) {
const abx = B[0] - A[0], aby = B[1] - A[1];
const ab2 = abx * abx + aby * aby;
if (ab2 === 0) return Math.hypot(P[0] - A[0], P[1] - A[1]);
const t = Math.max(0, Math.min(1, ((P[0] - A[0]) * abx + (P[1] - A[1]) * aby) / ab2));
const cx = A[0] + t * abx, cy = A[1] + t * aby;
return Math.hypot(P[0] - cx, P[1] - cy);
}
function capsuleClear(A, B, capsuleRadius, obstacles, obstacleRadius, margin) {
const minDist = capsuleRadius + obstacleRadius + margin;
for (const C of obstacles) {
if (distPointToSegment(C, A, B) < minDist) return false;
}
return true;
}
function findBendWaypoint(A, B, R, obstacles, obstacleRadius, margin) {
const midX = (A[0] + B[0]) / 2, midY = (A[1] + B[1]) / 2;
const dx = B[0] - A[0], dy = B[1] - A[1];
const len = Math.hypot(dx, dy);
if (len === 0) return null;
const perpX = -dy / len, perpY = dx / len;
const step = R + obstacleRadius + margin + 1;
for (let k = 1; k <= 10; k++) {
for (const sign of [1, -1]) {
const offset = k * step;
const P = [midX + sign * offset * perpX, midY + sign * offset * perpY];
if (capsuleClear(A, P, R, obstacles, obstacleRadius, margin) &&
capsuleClear(P, B, R, obstacles, obstacleRadius, margin)) {
return P;
}
}
}
return null;
}
export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRadius, spacing, keepoutRadius) {
if (cellIndices.length === 0) {
return { padIndices: [], edges: [], blocked: null };
}
if (cellIndices.length === 1) {
return { padIndices: cellIndices.slice(), edges: [], blocked: null };
}
const edgePairs = buildEdgePairs(cellIndices, positions, cellRadius, spacing);
const selectedSet = new Set(cellIndices);
const obstacles = positions.filter((_, i) => !selectedSet.has(i));
const margin = Math.max(spacing, 0.3);
const R = padRadius;
const edges = [];
for (const [i, j] of edgePairs) {
const A = positions[i], B = positions[j];
if (capsuleClear(A, B, R, obstacles, keepoutRadius, margin)) {
edges.push({ from: i, to: j, waypoints: [] });
} else {
const P = findBendWaypoint(A, B, R, obstacles, keepoutRadius, margin);
if (P) {
edges.push({ from: i, to: j, waypoints: [P] });
} else {
return {
padIndices: cellIndices.slice(),
edges,
blocked: { from: i, to: j, reason: 'no clear route between these cells' },
};
}
}
}
return { padIndices: cellIndices.slice(), edges, blocked: null };
}

57
src/busbar-model.js Normal file
View File

@@ -0,0 +1,57 @@
import { ocRef } from './oc.js';
export function build3DBusbar(geometry, positions, padRadius, zBase, thickness) {
const oc = ocRef.instance;
if (!oc || geometry.padIndices.length === 0) return null;
const shapes = [];
for (const i of geometry.padIndices) {
if (!positions[i]) continue;
const [x, y] = positions[i];
const ax = new oc.gp_Ax2(new oc.gp_Pnt(x, y, zBase), oc.gp.prototype.DZ());
shapes.push(new oc.BRepPrimAPI_MakeCylinder(ax, padRadius, thickness).Shape());
}
for (const edge of geometry.edges) {
const pts = [positions[edge.from], ...edge.waypoints, positions[edge.to]];
for (let k = 1; k < pts.length - 1; k++) {
const [wx, wy] = pts[k];
const ax = new oc.gp_Ax2(new oc.gp_Pnt(wx, wy, zBase), oc.gp.prototype.DZ());
shapes.push(new oc.BRepPrimAPI_MakeCylinder(ax, padRadius, thickness).Shape());
}
for (let k = 0; k < pts.length - 1; k++) {
const [x1, y1] = pts[k];
const [x2, y2] = pts[k + 1];
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 * padRadius, thickness).Shape();
const trans1 = new oc.gp_Trsf();
trans1.SetTranslation(new oc.gp_Vec(0, -padRadius, 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();
}
return combined;
}

102
src/busbar-preview.js Normal file
View File

@@ -0,0 +1,102 @@
import { canvasState } from './state.js';
function hexToRgba(hex, a) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${a})`;
}
function addCapsuleSubpath(ctx, x1, y1, x2, y2, r) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1e-6) return;
const angle = Math.atan2(dy, dx);
ctx.save();
ctx.translate(x1, y1);
ctx.rotate(angle);
ctx.rect(0, -r, len, 2 * r);
ctx.restore();
}
export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, padRadius, spacing, activeId) {
const canvas = document.getElementById('preview');
if (!canvas) return;
const t = canvasState.viewTransform;
if (!t || busbars.length === 0) return;
const ctx = canvas.getContext('2d');
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;
ctx.save();
ctx.translate(canvasState.panX, canvasState.panY);
ctx.scale(canvasState.zoom, canvasState.zoom);
const zoom = canvasState.zoom;
busbars.forEach((busbar, idx) => {
const geom = geometries[idx];
if (!geom || busbar.cellIndices.length === 0) return;
const isActive = busbar.id === activeId;
const fillAlpha = isActive ? 0.45 : 0.3;
ctx.fillStyle = hexToRgba(busbar.color, fillAlpha);
ctx.beginPath();
for (const i of busbar.cellIndices) {
if (!positions[i]) continue;
const [x, y] = positions[i];
const sx = toScreenX(x), sy = toScreenY(y);
ctx.moveTo(sx + padRadiusScreen, sy);
ctx.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2);
}
for (const edge of geom.edges) {
const pts = [positions[edge.from], ...edge.waypoints, positions[edge.to]];
for (let k = 1; k < pts.length - 1; k++) {
const [wx, wy] = pts[k];
const sx = toScreenX(wx), sy = toScreenY(wy);
ctx.moveTo(sx + padRadiusScreen, sy);
ctx.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2);
}
for (let k = 0; k < pts.length - 1; k++) {
addCapsuleSubpath(
ctx,
toScreenX(pts[k][0]), toScreenY(pts[k][1]),
toScreenX(pts[k + 1][0]), toScreenY(pts[k + 1][1]),
padRadiusScreen
);
}
}
ctx.fill();
ctx.strokeStyle = hexToRgba(busbar.color, isActive ? 1.0 : 0.85);
ctx.lineWidth = (isActive ? 2.5 : 1.5) / zoom;
for (const i of busbar.cellIndices) {
if (!positions[i]) continue;
const [x, y] = positions[i];
const sx = toScreenX(x), sy = toScreenY(y);
ctx.beginPath();
ctx.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2);
ctx.stroke();
}
if (geom.blocked) {
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 3 / zoom;
ctx.setLineDash([8 / zoom, 5 / zoom]);
const A = positions[geom.blocked.from];
const B = positions[geom.blocked.to];
if (A && B) {
ctx.beginPath();
ctx.moveTo(toScreenX(A[0]), toScreenY(A[1]));
ctx.lineTo(toScreenX(B[0]), toScreenY(B[1]));
ctx.stroke();
}
ctx.setLineDash([]);
}
});
ctx.restore();
}

67
src/busbar-ui.js Normal file
View File

@@ -0,0 +1,67 @@
import { busbarStore } from './busbars.js';
function escapeHtml(s) {
return s.replace(/[&<>"']/g, ch => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[ch]));
}
export function renderBusbarList(blockedByBusbarId = {}) {
const container = document.getElementById('busbarList');
if (!container) return;
container.innerHTML = '';
if (busbarStore.list.length === 0) {
const empty = document.createElement('div');
empty.className = 'busbar-empty';
empty.textContent = 'No busbars. Click "Add Busbar" then click cells in the preview.';
container.appendChild(empty);
return;
}
busbarStore.list.forEach(bb => {
const row = document.createElement('div');
row.className = 'busbar-row' + (bb.id === busbarStore.activeId ? ' active' : '');
row.dataset.id = bb.id;
row.innerHTML = `
<div class="busbar-header">
<div class="busbar-swatch" style="background:${bb.color}"></div>
<input class="busbar-name" type="text" value="${escapeHtml(bb.name)}">
<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-thickness-label">Thickness
<input class="busbar-thickness" type="number" value="${bb.thickness}" step="0.1" min="0.1">
</label>
</div>
${blockedByBusbarId[bb.id] ? `<div class="busbar-blocked">⚠ ${escapeHtml(blockedByBusbarId[bb.id])}</div>` : ''}
`;
row.addEventListener('click', (e) => {
if (e.target.closest('input') || e.target.closest('button')) return;
busbarStore.setActive(bb.id);
});
row.querySelector('.busbar-name').addEventListener('change', (e) => {
busbarStore.rename(bb.id, e.target.value);
});
row.querySelector('.busbar-thickness').addEventListener('change', (e) => {
const v = parseFloat(e.target.value);
if (v > 0) busbarStore.setThickness(bb.id, v);
});
row.querySelector('.busbar-del').addEventListener('click', (e) => {
e.stopPropagation();
busbarStore.remove(bb.id);
});
container.appendChild(row);
});
}
export function initBusbarUI() {
const addBtn = document.getElementById('addBusbarBtn');
if (addBtn) {
addBtn.addEventListener('click', () => busbarStore.add());
}
}

87
src/busbars.js Normal file
View File

@@ -0,0 +1,87 @@
const PALETTE = [
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
'#a855f7', '#ec4899', '#14b8a6', '#f97316',
];
let nextId = 1;
let paletteIdx = 0;
export const busbarStore = {
list: [],
activeId: null,
listeners: new Set(),
subscribe(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
},
_notify() {
this.listeners.forEach(fn => fn());
},
add() {
const busbar = {
id: 'bb-' + (nextId++),
name: `Busbar ${this.list.length + 1}`,
color: PALETTE[paletteIdx % PALETTE.length],
cellIndices: [],
thickness: 1.0,
};
paletteIdx++;
this.list.push(busbar);
this.activeId = busbar.id;
this._notify();
return busbar;
},
remove(id) {
this.list = this.list.filter(b => b.id !== id);
if (this.activeId === id) {
this.activeId = this.list.length ? this.list[0].id : null;
}
this._notify();
},
rename(id, name) {
const b = this.list.find(b => b.id === id);
if (b) { b.name = name; this._notify(); }
},
setColor(id, color) {
const b = this.list.find(b => b.id === id);
if (b) { b.color = color; this._notify(); }
},
setThickness(id, thickness) {
const b = this.list.find(b => b.id === id);
if (b) { b.thickness = thickness; this._notify(); }
},
setActive(id) {
this.activeId = id;
this._notify();
},
getActive() {
return this.list.find(b => b.id === this.activeId) || null;
},
toggleCell(cellIndex) {
const b = this.getActive();
if (!b) return false;
const idx = b.cellIndices.indexOf(cellIndex);
if (idx >= 0) b.cellIndices.splice(idx, 1);
else b.cellIndices.push(cellIndex);
this._notify();
return true;
},
clearAll() {
if (this.list.length === 0) return;
this.list = [];
this.activeId = null;
paletteIdx = 0;
this._notify();
},
};

76
src/layouts.js Normal file
View File

@@ -0,0 +1,76 @@
import { positionCache } from './state.js';
export function generateGridLayout(xDim, yDim, spacing, cellSize) {
const positions = [];
const radius = cellSize / 2;
const xStart = radius + spacing;
const yStart = radius + spacing;
for (let y = yStart; y + radius + spacing <= yDim; y += cellSize + spacing) {
for (let x = xStart; x + radius + spacing <= xDim; x += cellSize + spacing) {
positions.push([x, y]);
}
}
return positions;
}
export function generateHoneycombLayout(xDim, yDim, spacing, cellSize) {
const positions = [];
const radius = cellSize / 2;
let y = radius + spacing;
let row = 0;
while (y + radius + spacing <= yDim) {
const xOffset = (row % 2 === 0) ? 0 : (cellSize + spacing) / 2;
let x = radius + spacing + xOffset;
while (x + radius + spacing <= xDim) {
positions.push([x, y]);
x += cellSize + spacing;
}
y += Math.sqrt(3) * (radius + spacing / 2);
row++;
}
return positions;
}
export function generateVerticalHoneycombLayout(xDim, yDim, spacing, cellSize) {
const positions = [];
const radius = cellSize / 2;
let x = radius + spacing;
let col = 0;
while (x + radius + spacing <= xDim) {
const yOffset = (col % 2 === 0) ? 0 : (cellSize + spacing) / 2;
let y = radius + spacing + yOffset;
while (y + radius + spacing <= yDim) {
positions.push([x, y]);
y += cellSize + spacing;
}
x += Math.sqrt(3) * (radius + spacing / 2);
col++;
}
return positions;
}
export function generateLayoutPositions(layoutType, xDim, yDim, spacing, cellSize) {
if (layoutType === 'grid') return generateGridLayout(xDim, yDim, spacing, cellSize);
if (layoutType === 'honeycomb') return generateHoneycombLayout(xDim, yDim, spacing, cellSize);
return generateVerticalHoneycombLayout(xDim, yDim, spacing, cellSize);
}
export function getCachedPositions(xDim, yDim, spacing, cellSize, layoutType) {
const configKey = `${xDim}_${yDim}_${spacing}_${cellSize}_${layoutType}`;
if (positionCache.key === configKey && positionCache.positions) {
return positionCache.positions;
}
const positions = generateLayoutPositions(layoutType, xDim, yDim, spacing, cellSize);
positionCache.key = configKey;
positionCache.positions = positions;
return positions;
}

271
src/main.js Normal file
View File

@@ -0,0 +1,271 @@
import { canvasState } from './state.js';
import { initOC } from './oc.js';
import { toggleBmsDiameter, initCustomSelects } from './ui.js';
import { drawPreview } from './preview.js';
import { updatePreview, generateLayout } from './app.js';
import { busbarStore } from './busbars.js';
import { initBusbarUI, renderBusbarList } from './busbar-ui.js';
const CLICK_PIXEL_THRESHOLD = 4;
function scaleCanvasForDPI() {
const canvas = document.getElementById('preview');
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
}
function wireInputs() {
const layoutInputs = ['xDim', 'yDim', 'spacing', 'cellSize', 'layoutType'];
layoutInputs.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', () => busbarStore.clearAll());
});
const dimensionInputs = ['xDim', 'yDim', 'spacing', 'cellSize', 'layoutType', 'height', 'coverThickness'];
dimensionInputs.forEach(id => {
const element = document.getElementById(id);
if (!element) return;
element.addEventListener('input', () => updatePreview(true));
element.addEventListener('change', () => updatePreview(true));
});
const visualInputs = ['bmsHolesType', 'roundedCorners', 'bmsHoleDiameter', 'ledgeWidth', 'tabWidth', 'tabDepth'];
visualInputs.forEach(id => {
const element = document.getElementById(id);
if (!element) return;
element.addEventListener('input', () => updatePreview(false));
element.addEventListener('change', () => updatePreview(false));
});
}
function redrawFromState() {
if (canvasState.currentPositions.length > 0) {
drawPreview(canvasState.currentPositions, canvasState.currentCellSize);
}
}
function canvasPointToWorld(cx, cy) {
const t = canvasState.viewTransform;
if (!t) return null;
const localX = (cx - canvasState.panX) / canvasState.zoom;
const localY = (cy - canvasState.panY) / canvasState.zoom;
const worldX = (localX - t.offsetX) / t.scale + t.minX - t.r - t.spacing;
const worldY = (localY - t.offsetY) / t.scale + t.minY - t.r - t.spacing;
return [worldX, worldY];
}
function handleCanvasClick(cx, cy) {
const active = busbarStore.getActive();
if (!active) return;
const world = canvasPointToWorld(cx, cy);
if (!world) return;
const cellRadius = canvasState.currentCellSize / 2;
let bestIdx = -1, bestDist = cellRadius;
canvasState.currentPositions.forEach(([x, y], i) => {
const d = Math.hypot(world[0] - x, world[1] - y);
if (d < bestDist) { bestDist = d; bestIdx = i; }
});
if (bestIdx >= 0) busbarStore.toggleCell(bestIdx);
}
function wireCanvasInteractions() {
const canvas = document.getElementById('preview');
if (!canvas) return;
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomSpeed = 0.1;
const delta = e.deltaY > 0 ? -zoomSpeed : zoomSpeed;
const newZoom = Math.max(0.2, Math.min(5.0, canvasState.zoom + delta));
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const zoomRatio = newZoom / canvasState.zoom;
canvasState.panX = mouseX - (mouseX - canvasState.panX) * zoomRatio;
canvasState.panY = mouseY - (mouseY - canvasState.panY) * zoomRatio;
canvasState.zoom = newZoom;
redrawFromState();
});
canvas.addEventListener('mousedown', (e) => {
canvasState.isDragging = true;
canvasState.dragStartX = e.clientX;
canvasState.dragStartY = e.clientY;
canvasState.dragMoved = false;
canvasState.lastMouseX = e.clientX;
canvasState.lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('mousemove', (e) => {
if (!canvasState.isDragging) return;
const totalDx = e.clientX - canvasState.dragStartX;
const totalDy = e.clientY - canvasState.dragStartY;
if (Math.abs(totalDx) > CLICK_PIXEL_THRESHOLD || Math.abs(totalDy) > CLICK_PIXEL_THRESHOLD) {
canvasState.dragMoved = true;
}
const deltaX = e.clientX - canvasState.lastMouseX;
const deltaY = e.clientY - canvasState.lastMouseY;
canvasState.panX += deltaX;
canvasState.panY += deltaY;
canvasState.lastMouseX = e.clientX;
canvasState.lastMouseY = e.clientY;
if (canvasState.currentPositions.length > 0) {
requestAnimationFrame(() => drawPreview(canvasState.currentPositions, canvasState.currentCellSize));
}
});
canvas.addEventListener('mouseup', (e) => {
if (canvasState.isDragging && !canvasState.dragMoved) {
const rect = canvas.getBoundingClientRect();
handleCanvasClick(e.clientX - rect.left, e.clientY - rect.top);
}
canvasState.isDragging = false;
canvasState.dragMoved = false;
canvas.style.cursor = 'grab';
});
canvas.addEventListener('mouseleave', () => {
canvasState.isDragging = false;
canvasState.dragMoved = false;
canvas.style.cursor = 'grab';
});
let touchStartDistance = 0;
let touchStartZoom = 1.0;
let touchStartPanX = 0;
let touchStartPanY = 0;
let touchCenterX = 0;
let touchCenterY = 0;
let lastTouchX = 0;
let lastTouchY = 0;
let touchStartClientX = 0;
let touchStartClientY = 0;
let touchMoved = false;
let isTouching = false;
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
if (e.touches.length === 1) {
isTouching = true;
lastTouchX = e.touches[0].clientX;
lastTouchY = e.touches[0].clientY;
touchStartClientX = lastTouchX;
touchStartClientY = lastTouchY;
touchMoved = false;
} else if (e.touches.length === 2) {
isTouching = false;
const t1 = e.touches[0];
const t2 = e.touches[1];
touchStartDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
touchStartZoom = canvasState.zoom;
touchStartPanX = canvasState.panX;
touchStartPanY = canvasState.panY;
const rect = canvas.getBoundingClientRect();
touchCenterX = ((t1.clientX + t2.clientX) / 2) - rect.left;
touchCenterY = ((t1.clientY + t2.clientY) / 2) - rect.top;
}
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (e.touches.length === 1 && isTouching) {
const touch = e.touches[0];
const totalDx = touch.clientX - touchStartClientX;
const totalDy = touch.clientY - touchStartClientY;
if (Math.abs(totalDx) > CLICK_PIXEL_THRESHOLD || Math.abs(totalDy) > CLICK_PIXEL_THRESHOLD) {
touchMoved = true;
}
canvasState.panX += touch.clientX - lastTouchX;
canvasState.panY += touch.clientY - lastTouchY;
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
if (canvasState.currentPositions.length > 0) {
requestAnimationFrame(() => drawPreview(canvasState.currentPositions, canvasState.currentCellSize));
}
} else if (e.touches.length === 2) {
const t1 = e.touches[0];
const t2 = e.touches[1];
const currentDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
const zoomRatio = currentDistance / touchStartDistance;
const newZoom = Math.max(0.2, Math.min(5.0, touchStartZoom * zoomRatio));
const zoomChange = newZoom / touchStartZoom;
canvasState.panX = touchCenterX - (touchCenterX - touchStartPanX) * zoomChange;
canvasState.panY = touchCenterY - (touchCenterY - touchStartPanY) * zoomChange;
canvasState.zoom = newZoom;
if (canvasState.currentPositions.length > 0) {
requestAnimationFrame(() => drawPreview(canvasState.currentPositions, canvasState.currentCellSize));
}
}
});
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
if (e.changedTouches.length > 0 && isTouching && !touchMoved) {
const t = e.changedTouches[0];
const rect = canvas.getBoundingClientRect();
handleCanvasClick(t.clientX - rect.left, t.clientY - rect.top);
}
if (e.touches.length === 0) {
isTouching = false;
touchMoved = false;
}
if (e.touches.length < 2) touchStartDistance = 0;
});
canvas.addEventListener('touchcancel', () => {
isTouching = false;
touchMoved = false;
touchStartDistance = 0;
});
canvas.style.cursor = 'grab';
}
async function initializeApp() {
scaleCanvasForDPI();
initCustomSelects();
const bmsTypeSelect = document.getElementById('bmsHolesType');
if (bmsTypeSelect) {
bmsTypeSelect.addEventListener('change', toggleBmsDiameter);
toggleBmsDiameter();
}
const generateBtn = document.getElementById('generateBtn');
if (generateBtn) {
generateBtn.addEventListener('click', generateLayout);
}
initBusbarUI();
renderBusbarList();
busbarStore.subscribe(() => updatePreview(false));
wireInputs();
wireCanvasInteractions();
await initOC();
setTimeout(() => updatePreview(true), 100);
}
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}

401
src/model.js Normal file
View File

@@ -0,0 +1,401 @@
import { ocRef } from './oc.js';
export function create3DModel(positions, config) {
const oc = ocRef.instance;
if (!oc || positions.length === 0) return null;
const {
cellSize,
spacing,
height,
terminalDiameter,
terminalDepth,
coverThickness,
ledgeWidth,
roundedCorners,
bmsHoles,
useTabs,
useFullCircles,
filletBms,
} = config;
const r = cellSize / 2;
const minX = Math.min(...positions.map(p => p[0])) - r - spacing;
const minY = Math.min(...positions.map(p => p[1])) - r - spacing;
const maxX = Math.max(...positions.map(p => p[0])) + r + spacing;
const maxY = Math.max(...positions.map(p => p[1])) + r + spacing;
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const adjusted = positions.map(([x, y]) => [x - centerX, y - centerY]);
const width = maxX - minX;
const length = maxY - minY;
const perfStart = performance.now();
let perfLast = perfStart;
const logTime = (label) => {
const now = performance.now();
perfLast = now;
};
const boxMaker = new oc.BRepPrimAPI_MakeBox(width, length, height);
let base = boxMaker.Shape();
const translation = new oc.gp_Trsf();
translation.SetTranslation(new oc.gp_Vec(-width / 2, -length / 2, 0));
const transform = new oc.BRepBuilderAPI_Transform(base, translation, false);
base = transform.Shape();
if (roundedCorners) {
try {
const cornerRadius = 5.0;
const forEachEdge = (shape, callback) => {
const edgeHashes = {};
let edgeIndex = 0;
const anExplorer = new oc.TopExp_Explorer(shape, oc.TopAbs_EDGE);
for (anExplorer.Init(shape, oc.TopAbs_EDGE); anExplorer.More(); anExplorer.Next()) {
const edge = oc.TopoDS.prototype.Edge(anExplorer.Current());
const edgeHash = edge.HashCode(100000000);
if (!edgeHashes.hasOwnProperty(edgeHash)) {
edgeHashes[edgeHash] = edgeIndex;
callback(edgeIndex++, edge);
}
}
return edgeHashes;
};
const verticalEdgeIndices = [];
forEachEdge(base, (index, edge) => {
try {
const bbox = new oc.Bnd_Box();
oc.BRepBndLib.prototype.Add(edge, bbox, false);
const bboxMin = bbox.CornerMin();
const bboxMax = bbox.CornerMax();
const dx = Math.abs(bboxMax.X() - bboxMin.X());
const dy = Math.abs(bboxMax.Y() - bboxMin.Y());
const dz = Math.abs(bboxMax.Z() - bboxMin.Z());
if (dx < 1.0 && dy < 1.0 && dz > height * 0.8) {
verticalEdgeIndices.push(index);
}
} catch (e) { /* ignore */ }
});
if (verticalEdgeIndices.length > 0) {
const mkFillet = new oc.BRepFilletAPI_MakeFillet(base);
let edgeCount = 0;
forEachEdge(base, (index, edge) => {
if (verticalEdgeIndices.includes(index)) {
try {
mkFillet.Add(cornerRadius, edge);
edgeCount++;
} catch (e) {
console.error(` Failed to add edge ${index}:`, e.message);
}
}
});
if (edgeCount > 0) {
base = new oc.TopoDS_Solid(mkFillet.Shape());
console.log(' Applied rounded corners');
logTime('Rounded corners');
}
} else {
console.log(' No vertical edges found to fillet');
}
} catch (e) {
console.error(' Fillet operation failed:', e.message);
console.log(' Continuing without rounded corners');
}
}
// Build cell hole cutters.
// When a ledge is requested (ledgeWidth > 0 || coverThickness > 0) we use a
// two-step bore so the ledge ring stays part of the base body:
// • outer bore (radius r) from z=coverThickness to z=height
// • inner bore (radius r - ledgeWidth) from z=0 to z=height
const hasLedge = (ledgeWidth > 0 || coverThickness > 0);
if (!hasLedge) {
const cutters = adjusted.map(([x, y]) => {
const ax = new oc.gp_Ax2(new oc.gp_Pnt(x, y, 0), oc.gp.prototype.DZ());
return new oc.BRepPrimAPI_MakeCylinder(ax, r, height).Shape();
});
let allCutters = cutters[0];
for (let i = 1; i < cutters.length; i++) {
allCutters = new oc.BRepAlgoAPI_Fuse(allCutters, cutters[i]).Shape();
}
base = new oc.BRepAlgoAPI_Cut(base, allCutters).Shape();
} else {
const outerZ = coverThickness;
const outerH = height - coverThickness;
const innerR = Math.max(0.1, r - ledgeWidth);
const outerCutters = adjusted.map(([x, y]) => {
const ax = new oc.gp_Ax2(new oc.gp_Pnt(x, y, outerZ), oc.gp.prototype.DZ());
return new oc.BRepPrimAPI_MakeCylinder(ax, r, outerH).Shape();
});
let allOuter = outerCutters[0];
for (let i = 1; i < outerCutters.length; i++) {
allOuter = new oc.BRepAlgoAPI_Fuse(allOuter, outerCutters[i]).Shape();
}
base = new oc.BRepAlgoAPI_Cut(base, allOuter).Shape();
const innerCutters = adjusted.map(([x, y]) => {
const ax = new oc.gp_Ax2(new oc.gp_Pnt(x, y, 0), oc.gp.prototype.DZ());
return new oc.BRepPrimAPI_MakeCylinder(ax, innerR, height).Shape();
});
let allInner = innerCutters[0];
for (let i = 1; i < innerCutters.length; i++) {
allInner = new oc.BRepAlgoAPI_Fuse(allInner, innerCutters[i]).Shape();
}
base = new oc.BRepAlgoAPI_Cut(base, allInner).Shape();
}
console.log(' Cell holes cut' + (hasLedge ? ' (with integrated ledge)' : ''));
logTime('Cell holes');
if (bmsHoles) {
const holeDiameter = config.bmsHoleDiameter || 4.0;
let holeYTop, holeYBottom;
if (useFullCircles) {
holeYTop = null;
holeYBottom = null;
} else {
holeYTop = length / 2;
holeYBottom = -length / 2;
}
const rows = {};
adjusted.forEach(([x, y]) => {
const yKey = Math.round(y * 1000);
if (!rows[yKey]) rows[yKey] = [];
rows[yKey].push([x, y]);
});
const rowKeys = Object.keys(rows).map(k => parseInt(k)).sort((a, b) => b - a);
const topYKey = rowKeys[0];
const bottomYKey = rowKeys[rowKeys.length - 1];
if (useFullCircles) {
const topCellY = rows[topYKey][0][1];
const bottomCellY = rows[bottomYKey][0][1];
const wallTop = length / 2;
const wallBottom = -length / 2;
const solveEquilateral3D = (wallY, cellY, x1, x2) => {
const xMid = (x1 + x2) / 2;
const flip = cellY < wallY ? -1 : 1;
let lo = flip > 0 ? -Math.PI / 2 : 0;
let hi = flip > 0 ? 0 : Math.PI / 2;
for (let i = 0; i < 80; i++) {
const alpha = (lo + hi) / 2;
const Bx = x1 + r * Math.cos(alpha);
const By = cellY + r * Math.sin(alpha);
const d = xMid - Bx;
const h = (By - wallY) * flip;
const diff = h - d * Math.sqrt(3);
if (Math.abs(diff) < 1e-8) break;
if (diff < 0) { flip > 0 ? (lo = alpha) : (hi = alpha); }
else { flip > 0 ? (hi = alpha) : (lo = alpha); }
}
const alpha = (lo + hi) / 2;
const By = cellY + r * Math.sin(alpha);
return (wallY + 2 * By) / 3;
};
holeYTop = solveEquilateral3D(wallTop, topCellY, rows[topYKey][0][0], rows[topYKey][1][0]);
holeYBottom = solveEquilateral3D(wallBottom, bottomCellY, rows[bottomYKey][0][0], rows[bottomYKey][1][0]);
}
const topRow = rows[topYKey].sort((a, b) => a[0] - b[0]);
const bottomRow = rows[bottomYKey].sort((a, b) => a[0] - b[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]);
}
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]);
}
const allBmsHoles = [...topHoles, ...bottomHoles];
if (useTabs) {
const slotWidth = config.tabWidth || holeDiameter;
const slotInset = config.tabDepth || 1.0;
const topEdgeY = length / 2;
const bottomEdgeY = -length / 2;
const allSlots = [];
topHoles.forEach(([xPos]) => {
const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, height);
const slot = slotBox.Shape();
const trans = new oc.gp_Trsf();
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, topEdgeY - slotInset, 0));
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 slot = slotBox.Shape();
const trans = new oc.gp_Trsf();
trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, bottomEdgeY, 0));
const slotTransform = new oc.BRepBuilderAPI_Transform(slot, trans, false);
allSlots.push(slotTransform.Shape());
});
if (allSlots.length > 0) {
if (allSlots.length === 1) {
base = new oc.BRepAlgoAPI_Cut(base, allSlots[0]).Shape();
} else {
let compound = allSlots[0];
for (let i = 1; i < allSlots.length; i++) {
compound = new oc.BRepAlgoAPI_Fuse(compound, allSlots[i]).Shape();
}
base = new oc.BRepAlgoAPI_Cut(base, compound).Shape();
}
}
logTime('Edge tabs');
} else {
if (allBmsHoles.length <= 10) {
allBmsHoles.forEach(([x, y]) => {
const cylinderAxis = new oc.gp_Ax2(new oc.gp_Pnt(x, y, 0), oc.gp.prototype.DZ());
const cylinder = new oc.BRepPrimAPI_MakeCylinder(cylinderAxis, holeDiameter / 2, height).Shape();
base = new oc.BRepAlgoAPI_Cut(base, cylinder).Shape();
});
} else {
const bmsCylinders = allBmsHoles.map(([x, y]) => {
const cylinderAxis = new oc.gp_Ax2(new oc.gp_Pnt(x, y, 0), oc.gp.prototype.DZ());
return new oc.BRepPrimAPI_MakeCylinder(cylinderAxis, holeDiameter / 2, height).Shape();
});
let compound = bmsCylinders[0];
for (let i = 1; i < bmsCylinders.length; i++) {
compound = new oc.BRepAlgoAPI_Fuse(compound, bmsCylinders[i]).Shape();
}
base = new oc.BRepAlgoAPI_Cut(base, compound).Shape();
}
logTime('BMS holes');
if (filletBms && allBmsHoles.length > 0) {
try {
console.log(' Filleting BMS hole edges...');
const bmsFilletRadius = 0.5;
const forEachEdge = (shape, callback) => {
const edgeHashes = {};
let edgeIndex = 0;
const anExplorer = new oc.TopExp_Explorer(shape, oc.TopAbs_EDGE);
for (anExplorer.Init(shape, oc.TopAbs_EDGE); anExplorer.More(); anExplorer.Next()) {
const edge = oc.TopoDS.prototype.Edge(anExplorer.Current());
const edgeHash = edge.HashCode(100000000);
if (!edgeHashes.hasOwnProperty(edgeHash)) {
edgeHashes[edgeHash] = edgeIndex;
callback(edgeIndex++, edge);
}
}
return edgeHashes;
};
const bmsEdgeIndices = [];
forEachEdge(base, (index, edge) => {
try {
const bbox = new oc.Bnd_Box();
oc.BRepBndLib.prototype.Add(edge, bbox, false);
const bboxMin = bbox.CornerMin();
const bboxMax = bbox.CornerMax();
const centerX = (bboxMin.X() + bboxMax.X()) / 2;
const centerY = (bboxMin.Y() + bboxMax.Y()) / 2;
const centerZ = (bboxMin.Z() + bboxMax.Z()) / 2;
for (const [holeX, holeY] of allBmsHoles) {
const distXY = Math.hypot(centerX - holeX, centerY - holeY);
const isNearHole = distXY < holeDiameter;
const isAtTopOrBottom = Math.abs(centerZ - 0) < 1.0 || Math.abs(centerZ - height) < 1.0;
if (isNearHole && isAtTopOrBottom) {
bmsEdgeIndices.push(index);
break;
}
}
} catch (e) { /* ignore */ }
});
if (bmsEdgeIndices.length > 0) {
const mkFillet = new oc.BRepFilletAPI_MakeFillet(base);
let edgeCount = 0;
forEachEdge(base, (index, edge) => {
if (bmsEdgeIndices.includes(index)) {
try {
mkFillet.Add(bmsFilletRadius, edge);
edgeCount++;
} catch (e) {
console.error(` Failed to add BMS edge ${index}`);
}
}
});
if (edgeCount > 0) {
base = new oc.TopoDS_Solid(mkFillet.Shape());
console.log(` Applied ${bmsFilletRadius}mm fillet to ${edgeCount} BMS hole edges`);
}
}
} catch (e) {
console.error(' BMS fillet failed:', e.message);
console.log(' Continuing without BMS filleting');
}
}
}
}
if (adjusted.length <= 10) {
adjusted.forEach(([x, y]) => {
const cylinderAxis = new oc.gp_Ax2(new oc.gp_Pnt(x, y, height - terminalDepth), oc.gp.prototype.DZ());
const cylinder = new oc.BRepPrimAPI_MakeCylinder(cylinderAxis, terminalDiameter / 2, terminalDepth).Shape();
base = new oc.BRepAlgoAPI_Cut(base, cylinder).Shape();
});
} else {
const terminals = adjusted.map(([x, y]) => {
const cylinderAxis = new oc.gp_Ax2(new oc.gp_Pnt(x, y, height - terminalDepth), oc.gp.prototype.DZ());
return new oc.BRepPrimAPI_MakeCylinder(cylinderAxis, terminalDiameter / 2, terminalDepth).Shape();
});
const batchSize = 30;
const batches = [];
for (let i = 0; i < terminals.length; i += batchSize) {
const end = Math.min(i + batchSize, terminals.length);
let batch = terminals[i];
for (let j = i + 1; j < end; j++) {
batch = new oc.BRepAlgoAPI_Fuse(batch, terminals[j]).Shape();
}
batches.push(batch);
}
let allTerminals = batches[0];
for (let i = 1; i < batches.length; i++) {
allTerminals = new oc.BRepAlgoAPI_Fuse(allTerminals, batches[i]).Shape();
}
base = new oc.BRepAlgoAPI_Cut(base, allTerminals).Shape();
}
console.log(' Terminal recesses cut');
logTime('Terminal recesses');
if (hasLedge) {
console.log(' Ledge integrated into cell holes');
}
logTime('Ledge rings');
logTime('TOTAL GENERATION');
return base;
}

23
src/oc.js Normal file
View File

@@ -0,0 +1,23 @@
import { showStatus, showLoading } from './ui.js';
export const ocRef = {
instance: null,
initialized: false,
};
export async function initOC() {
if (ocRef.initialized) return;
try {
ocRef.instance = await opencascade({
locateFile: () => 'vendor/opencascade.wasm.wasm'
});
ocRef.initialized = true;
console.log('OpenCascade initialized successfully');
showLoading(false);
} catch (error) {
console.error('Failed to initialize OpenCascade:', error);
showStatus('Failed to initialize 3D engine. Please ensure opencascade.wasm.js and opencascade.wasm.wasm are in vendor/.', 'error');
showLoading(false);
}
}

481
src/preview.js Normal file
View File

@@ -0,0 +1,481 @@
import { canvasState } from './state.js';
import { showStatus } from './ui.js';
export function clearCanvas() {
const canvas = document.getElementById('preview');
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#1e293b';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
export function drawPreview(positions, cellSize) {
canvasState.currentPositions = positions;
canvasState.currentCellSize = cellSize;
const canvas = document.getElementById('preview');
if (!canvas) {
console.error('Canvas element not found!');
return;
}
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#1e293b';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (positions.length === 0) return;
ctx.save();
ctx.translate(canvasState.panX, canvasState.panY);
ctx.scale(canvasState.zoom, canvasState.zoom);
const spacing = parseFloat(document.getElementById('spacing').value);
const bmsHolesType = document.getElementById('bmsHolesType').value;
const bmsHoles = bmsHolesType !== 'off';
const useTabs = bmsHolesType === 'tabs';
const useFullCircles = bmsHolesType === 'fullcircles';
const circleHoleOffset = false;
const roundedCorners = document.getElementById('roundedCorners').checked;
const bmsHoleDiameter = parseFloat(document.getElementById('bmsHoleDiameter').value) || 4.0;
const ledgeWidth = parseFloat(document.getElementById('ledgeWidth').value) || 0;
const r = cellSize / 2;
const minX = Math.min(...positions.map(p => p[0]));
const minY = Math.min(...positions.map(p => p[1]));
const maxX = Math.max(...positions.map(p => p[0]));
const maxY = Math.max(...positions.map(p => p[1]));
const packWidth = maxX - minX + cellSize + spacing * 2;
const packHeight = maxY - minY + cellSize + spacing * 2;
const rect = canvas.getBoundingClientRect();
const canvasDisplayWidth = rect.width;
const canvasDisplayHeight = rect.height;
const padding = 80;
const scaleX = (canvasDisplayWidth - padding * 2) / packWidth;
const scaleY = (canvasDisplayHeight - padding * 2) / packHeight;
const scale = Math.min(scaleX, scaleY);
const offsetX = (canvasDisplayWidth - packWidth * scale) / 2;
const offsetY = (canvasDisplayHeight - packHeight * scale) / 2;
canvasState.viewTransform = { offsetX, offsetY, scale, minX, minY, spacing, r };
const zoom = canvasState.zoom;
if (roundedCorners) {
const cornerRadius = 5.0 * scale;
const x = offsetX;
const y = offsetY;
const width = packWidth * scale;
const height = packHeight * scale;
ctx.fillStyle = 'rgba(100, 149, 237, 0.15)';
ctx.beginPath();
ctx.moveTo(x + cornerRadius, y);
ctx.lineTo(x + width - cornerRadius, y);
ctx.arcTo(x + width, y, x + width, y + cornerRadius, cornerRadius);
ctx.lineTo(x + width, y + height - cornerRadius);
ctx.arcTo(x + width, y + height, x + width - cornerRadius, y + height, cornerRadius);
ctx.lineTo(x + cornerRadius, y + height);
ctx.arcTo(x, y + height, x, y + height - cornerRadius, cornerRadius);
ctx.lineTo(x, y + cornerRadius);
ctx.arcTo(x, y, x + cornerRadius, y, cornerRadius);
ctx.closePath();
ctx.fill();
} else {
ctx.fillStyle = 'rgba(100, 149, 237, 0.15)';
ctx.fillRect(offsetX, offsetY, packWidth * scale, packHeight * scale);
}
ctx.strokeStyle = '#667eea';
ctx.lineWidth = 2 / zoom;
if (roundedCorners) {
const cornerRadius = 5.0 * scale;
const x = offsetX;
const y = offsetY;
const width = packWidth * scale;
const height = packHeight * scale;
ctx.beginPath();
ctx.moveTo(x + cornerRadius, y);
ctx.lineTo(x + width - cornerRadius, y);
ctx.arcTo(x + width, y, x + width, y + cornerRadius, cornerRadius);
ctx.lineTo(x + width, y + height - cornerRadius);
ctx.arcTo(x + width, y + height, x + width - cornerRadius, y + height, cornerRadius);
ctx.lineTo(x + cornerRadius, y + height);
ctx.arcTo(x, y + height, x, y + height - cornerRadius, cornerRadius);
ctx.lineTo(x, y + cornerRadius);
ctx.arcTo(x, y, x + cornerRadius, y, cornerRadius);
ctx.closePath();
ctx.stroke();
} else {
ctx.strokeRect(offsetX, offsetY, packWidth * scale, packHeight * scale);
}
const bmsHolePositions = [];
if (bmsHoles) {
const packMinY = minY - r - spacing;
const packMaxY = maxY + r + spacing;
const rows = {};
for (const [x, y] of positions) {
const key = Math.round(y * 1000);
if (!rows[key]) rows[key] = [];
rows[key].push([x, y]);
}
const rowKeys = Object.keys(rows).map(Number).sort((a, b) => a - b);
const topYKey = rowKeys[rowKeys.length - 1];
const bottomYKey = rowKeys[0];
const visualTopRowKey = rowKeys[0];
const visualBottomRowKey = rowKeys[rowKeys.length - 1];
rows[topYKey].sort((a, b) => a[0] - b[0]);
rows[bottomYKey].sort((a, b) => a[0] - b[0]);
const topY = rows[topYKey][0][1];
const bottomY = rows[bottomYKey][0][1];
let topEdge, bottomEdge;
if (useFullCircles) {
const visualTopCellY = rows[visualTopRowKey][0][1];
const visualBottomCellY = rows[visualBottomRowKey][0][1];
const topRow = rows[visualTopRowKey];
const botRow = rows[visualBottomRowKey];
const solveEquilateral = (wallY, cellY, x1, x2) => {
const xMid = (x1 + x2) / 2;
const flip = cellY < wallY ? -1 : 1;
let lo = -Math.PI / 2 * flip, hi = 0;
if (flip < 0) { lo = 0; hi = Math.PI / 2; }
for (let i = 0; i < 80; i++) {
const alpha = (lo + hi) / 2;
const Bx = x1 + r * Math.cos(alpha);
const By = cellY + r * Math.sin(alpha);
const d = xMid - Bx;
const h = (By - wallY) * flip;
const diff = h - d * Math.sqrt(3);
if (Math.abs(diff) < 1e-8) break;
if (diff < 0) { flip > 0 ? (lo = alpha) : (hi = alpha); }
else { flip > 0 ? (hi = alpha) : (lo = alpha); }
}
const alpha = (lo + hi) / 2;
const By = cellY + r * Math.sin(alpha);
return (wallY + 2 * By) / 3;
};
if (topRow.length >= 2)
topEdge = solveEquilateral(packMinY, visualTopCellY, topRow[0][0], topRow[1][0]);
if (botRow.length >= 2)
bottomEdge = solveEquilateral(packMaxY, visualBottomCellY, botRow[0][0], botRow[1][0]);
} else if (circleHoleOffset) {
const bmsHoleRadius = bmsHoleDiameter / 2;
const cellRadius = cellSize / 2;
const topRowCells = rows[topYKey];
const bottomRowCells = rows[bottomYKey];
const offsetDistance = bmsHoleRadius + 1.0;
if (topRowCells.length >= 2) {
topEdge = topY + cellRadius + offsetDistance;
} else {
topEdge = packMaxY;
}
if (bottomRowCells.length >= 2) {
bottomEdge = bottomY - cellRadius - offsetDistance;
} else {
bottomEdge = packMinY;
}
} else {
topEdge = packMaxY;
bottomEdge = packMinY;
}
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];
const wallY = packMinY;
const y = topEdge;
const flip = cellY < wallY ? -1 : 1;
let lo = flip > 0 ? -Math.PI / 2 : 0, hi = flip > 0 ? 0 : Math.PI / 2;
for (let it = 0; it < 80; it++) {
const alpha = (lo + hi) / 2;
const d = x - (x1 + r * Math.cos(alpha));
const h = (cellY + r * Math.sin(alpha) - wallY) * flip;
const diff = h - d * Math.sqrt(3);
if (Math.abs(diff) < 1e-8) break;
if (diff < 0) { flip > 0 ? (lo = alpha) : (hi = alpha); } else { flip > 0 ? (hi = alpha) : (lo = alpha); }
}
const alphaTop = (lo + hi) / 2;
const left = { x: x1 + r * Math.cos(alphaTop), y: cellY + r * Math.sin(alphaTop) };
const right = { x: x2 - r * Math.cos(alphaTop), y: cellY + r * Math.sin(alphaTop) };
const debugTri = useFullCircles ? { apex: { x, y: wallY }, left, right } : null;
bmsHolePositions.push({ x, y, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri });
}
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;
let lo = flip > 0 ? -Math.PI / 2 : 0, hi = flip > 0 ? 0 : Math.PI / 2;
for (let it = 0; it < 80; it++) {
const alpha = (lo + hi) / 2;
const d = x - (x1 + r * Math.cos(alpha));
const h = (cellY + r * Math.sin(alpha) - wallY) * flip;
const diff = h - d * Math.sqrt(3);
if (Math.abs(diff) < 1e-8) break;
if (diff < 0) { flip > 0 ? (lo = alpha) : (hi = alpha); } else { flip > 0 ? (hi = alpha) : (lo = alpha); }
}
const alphaBot = (lo + hi) / 2;
const left = { x: x1 + r * Math.cos(alphaBot), y: cellY + r * Math.sin(alphaBot) };
const right = { x: x2 - r * Math.cos(alphaBot), y: cellY + r * Math.sin(alphaBot) };
const debugTri = useFullCircles ? { apex: { x, y: wallY }, left, right } : null;
bmsHolePositions.push({ x, y, diameter: bmsHoleDiameter, isTab: false, isFull: useFullCircles, debugTri });
}
}
ctx.fillStyle = '#1e293b';
ctx.strokeStyle = 'rgba(102, 126, 234, 0.8)';
ctx.lineWidth = 1.5 / zoom;
for (const [x, y] of positions) {
const cx = (x - minX + r + spacing) * scale + offsetX;
const cy = (y - minY + r + spacing) * scale + offsetY;
const radius = r * scale;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
if (ledgeWidth > 0) {
const ledgeInnerRadius = (r - ledgeWidth) * scale;
ctx.strokeStyle = 'rgba(255, 193, 7, 0.8)';
ctx.setLineDash([3 / zoom, 3 / zoom]);
ctx.lineWidth = 1 / zoom;
ctx.beginPath();
ctx.arc(cx, cy, ledgeInnerRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = 'rgba(102, 126, 234, 0.8)';
ctx.lineWidth = 1.5 / zoom;
}
}
if (bmsHoles && bmsHolePositions.length > 0) {
if (useTabs) {
ctx.fillStyle = 'rgba(255, 193, 7, 0.5)';
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 tabWidth = tabWidthMm * scale;
const tabHeight = tabDepthMm * 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 {
ctx.fillRect(cx - tabWidth / 2, offsetY, tabWidth, tabHeight);
ctx.strokeRect(cx - tabWidth / 2, offsetY, tabWidth, tabHeight);
}
}
} else if (useFullCircles) {
let collisionError = null;
for (const hole of bmsHolePositions) {
const holeRadius = (hole.diameter / 2) * scale;
const cx = (hole.x - minX + r + spacing) * scale + offsetX;
const cy = (hole.y - minY + r + spacing) * scale + offsetY;
for (const [cellX, cellY] of positions) {
const ccx = (cellX - minX + r + spacing) * scale + offsetX;
const ccy = (cellY - minY + r + spacing) * scale + offsetY;
const dist = Math.sqrt((cx - ccx) ** 2 + (cy - ccy) ** 2);
if (dist < holeRadius + r * scale) {
const maxDiam = ((dist / scale) - r) * 2;
collisionError = `BMS hole too large, overlaps cell! Max diameter: ${maxDiam.toFixed(1)}mm`;
break;
}
}
if (collisionError) break;
}
if (collisionError) {
ctx.restore();
canvasState.currentPositions = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#1e293b';
ctx.fillRect(0, 0, canvas.width, canvas.height);
showStatus(collisionError, 'error');
return;
}
for (const hole of bmsHolePositions) {
const cx = (hole.x - minX + r + spacing) * scale + offsetX;
const cy = (hole.y - minY + r + spacing) * scale + offsetY;
const holeRadius = (hole.diameter / 2) * scale;
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'black';
ctx.beginPath();
ctx.arc(cx, cy, holeRadius, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
ctx.restore();
ctx.strokeStyle = 'rgba(16, 185, 129, 0.9)';
ctx.lineWidth = 1.5 / zoom;
ctx.beginPath();
ctx.arc(cx, cy, holeRadius, 0, Math.PI * 2);
ctx.stroke();
}
} else {
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
for (const hole of bmsHolePositions) {
const cx = (hole.x - minX + r + spacing) * scale + offsetX;
const holeRadius = (hole.diameter / 2) * scale;
ctx.fillStyle = 'black';
ctx.beginPath();
if (hole.y > maxY) {
const extension = 3 / zoom;
ctx.arc(cx, offsetY + packHeight * scale, holeRadius, Math.PI, 0, false);
ctx.lineTo(cx + holeRadius, offsetY + packHeight * scale + extension);
ctx.lineTo(cx - holeRadius, offsetY + packHeight * scale + extension);
} else {
const extension = 3 / zoom;
ctx.arc(cx, offsetY, holeRadius, 0, Math.PI, false);
ctx.lineTo(cx - holeRadius, offsetY - extension);
ctx.lineTo(cx + holeRadius, offsetY - extension);
}
ctx.closePath();
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
for (const hole of bmsHolePositions) {
const cx = (hole.x - minX + r + spacing) * scale + offsetX;
const holeRadius = (hole.diameter / 2) * scale;
ctx.strokeStyle = 'rgba(16, 185, 129, 0.9)';
ctx.lineWidth = 1.5 / zoom;
ctx.beginPath();
if (hole.y > maxY) {
ctx.arc(cx, offsetY + packHeight * scale, holeRadius, Math.PI, 0, false);
} else {
ctx.arc(cx, offsetY, holeRadius, 0, Math.PI, false);
}
ctx.stroke();
}
ctx.restore();
}
}
ctx.strokeStyle = '#94a3b8';
ctx.fillStyle = '#94a3b8';
ctx.lineWidth = 1 / zoom;
ctx.font = `${12 / zoom}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const dimOffset = 15 / zoom;
const arrowSize = 5 / zoom;
const widthY = offsetY + packHeight * scale + dimOffset;
ctx.setLineDash([2 / zoom, 2 / zoom]);
ctx.beginPath();
ctx.moveTo(offsetX, offsetY + packHeight * scale);
ctx.lineTo(offsetX, widthY + dimOffset / 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(offsetX + packWidth * scale, offsetY + packHeight * scale);
ctx.lineTo(offsetX + packWidth * scale, widthY + dimOffset / 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(offsetX, widthY);
ctx.lineTo(offsetX + packWidth * scale, widthY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(offsetX, widthY);
ctx.lineTo(offsetX + arrowSize, widthY - arrowSize / 2);
ctx.lineTo(offsetX + arrowSize, widthY + arrowSize / 2);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(offsetX + packWidth * scale, widthY);
ctx.lineTo(offsetX + packWidth * scale - arrowSize, widthY - arrowSize / 2);
ctx.lineTo(offsetX + packWidth * scale - arrowSize, widthY + arrowSize / 2);
ctx.closePath();
ctx.fill();
ctx.fillText(`${packWidth.toFixed(1)}mm`, offsetX + packWidth * scale / 2, widthY + dimOffset);
const heightX = offsetX + packWidth * scale + dimOffset;
ctx.setLineDash([2 / zoom, 2 / zoom]);
ctx.beginPath();
ctx.moveTo(offsetX + packWidth * scale, offsetY);
ctx.lineTo(heightX + dimOffset / 2, offsetY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(offsetX + packWidth * scale, offsetY + packHeight * scale);
ctx.lineTo(heightX + dimOffset / 2, offsetY + packHeight * scale);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(heightX, offsetY);
ctx.lineTo(heightX, offsetY + packHeight * scale);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(heightX, offsetY);
ctx.lineTo(heightX - arrowSize / 2, offsetY + arrowSize);
ctx.lineTo(heightX + arrowSize / 2, offsetY + arrowSize);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(heightX, offsetY + packHeight * scale);
ctx.lineTo(heightX - arrowSize / 2, offsetY + packHeight * scale - arrowSize);
ctx.lineTo(heightX + arrowSize / 2, offsetY + packHeight * scale - arrowSize);
ctx.closePath();
ctx.fill();
ctx.save();
ctx.translate(heightX + dimOffset, offsetY + packHeight * scale / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(`${packHeight.toFixed(1)}mm`, 0, 0);
ctx.restore();
ctx.restore();
}

19
src/state.js Normal file
View File

@@ -0,0 +1,19 @@
export const canvasState = {
zoom: 1.0,
panX: 0,
panY: 0,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
dragMoved: false,
lastMouseX: 0,
lastMouseY: 0,
currentPositions: [],
currentCellSize: 18,
viewTransform: null,
};
export const positionCache = {
key: null,
positions: null,
};

32
src/step-export.js Normal file
View File

@@ -0,0 +1,32 @@
import { ocRef } from './oc.js';
import { showStatus } from './ui.js';
export function downloadSTEP(shape, filename) {
const oc = ocRef.instance;
if (!oc || !shape) return;
try {
const writer = new oc.STEPControl_Writer();
writer.Transfer(shape, 0);
writer.Write(filename);
const fileData = oc.FS.readFile(filename);
const blob = new Blob([fileData], { type: 'application/step' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
oc.FS.unlink(filename);
showStatus(`Downloaded ${filename} successfully!`, 'success');
} catch (error) {
console.error('STEP export error:', error);
showStatus('Error exporting STEP file: ' + error.message, 'error');
}
}

94
src/ui.js Normal file
View File

@@ -0,0 +1,94 @@
export function showStatus(message, type = 'success') {
const status = document.getElementById('previewStats');
if (!status) return;
status.textContent = message;
if (type === 'error') {
status.style.color = '#ef4444';
} else if (type === 'success') {
status.style.color = '#10b981';
} else {
status.style.color = '#94a3b8';
}
}
export function showLoading(show, text = 'Generating 3D Model', subtext = 'Please be patient...') {
const overlay = document.getElementById('loadingOverlay');
if (!overlay) return;
const loadingText = document.getElementById('loadingText');
const loadingSubtext = document.getElementById('loadingSubtext');
if (loadingText) loadingText.textContent = text;
if (loadingSubtext) loadingSubtext.textContent = subtext;
if (show) {
overlay.classList.add('active');
overlay.style.display = 'flex';
} else {
overlay.classList.remove('active');
overlay.style.display = 'none';
}
}
export function toggleBmsDiameter() {
const bmsType = document.getElementById('bmsHolesType').value;
const diameterGroup = document.getElementById('bmsHoleDiameterGroup');
const tabDimsGroup = document.getElementById('tabDimensionsGroup');
diameterGroup.style.display =
(bmsType === 'halfcircles' || bmsType === 'fullcircles') ? 'block' : 'none';
tabDimsGroup.style.display = (bmsType === 'tabs') ? 'grid' : 'none';
}
export function initCustomSelects() {
const selects = document.querySelectorAll('select');
selects.forEach(select => {
const wrapper = document.createElement('div');
wrapper.className = 'custom-select';
select.parentNode.insertBefore(wrapper, select);
wrapper.appendChild(select);
const selected = document.createElement('div');
selected.className = 'select-selected';
selected.textContent = select.options[select.selectedIndex].text;
wrapper.appendChild(selected);
const itemsContainer = document.createElement('div');
itemsContainer.className = 'select-items';
Array.from(select.options).forEach((option, index) => {
const item = document.createElement('div');
item.textContent = option.text;
item.dataset.value = option.value;
if (index === select.selectedIndex) item.className = 'same-as-selected';
item.addEventListener('click', function (e) {
e.stopPropagation();
select.selectedIndex = index;
selected.textContent = this.textContent;
const prevSelected = itemsContainer.querySelector('.same-as-selected');
if (prevSelected) prevSelected.classList.remove('same-as-selected');
this.classList.add('same-as-selected');
selected.click();
select.dispatchEvent(new Event('change'));
});
itemsContainer.appendChild(item);
});
wrapper.appendChild(itemsContainer);
selected.addEventListener('click', function (e) {
e.stopPropagation();
closeAllSelect(this);
itemsContainer.classList.toggle('show');
this.classList.toggle('select-arrow-active');
});
});
document.addEventListener('click', closeAllSelect);
}
function closeAllSelect(element) {
const items = document.querySelectorAll('.select-items');
const selected = document.querySelectorAll('.select-selected');
items.forEach((item, index) => {
if (element !== selected[index]) {
item.classList.remove('show');
selected[index].classList.remove('select-arrow-active');
}
});
}

895
styles/main.css Normal file
View File

@@ -0,0 +1,895 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #0a0f1e;
min-height: 100vh;
color: #e2e8f0;
line-height: 1.6;
padding: 48px 24px;
}
.container {
max-width: 1400px;
margin: 0 auto;
transition: max-width 0.3s ease, padding 0.3s ease;
}
/* Header */
h1 {
font-size: 2.5em;
font-weight: 700;
color: #fff;
margin-bottom: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeInUp 0.6s ease-out;
}
h1:hover {
color: #6495ed;
text-shadow: 0 0 20px rgba(100, 149, 237, 0.5);
transform: translateX(5px);
}
.subtitle {
color: #94a3b8;
font-size: 1em;
margin-bottom: 32px;
transition: color 0.3s ease;
animation: fadeInUp 0.6s ease-out 0.1s both;
}
/* Main Layout */
.main-layout {
display: grid;
grid-template-columns: 400px 1fr;
gap: 24px;
transition: grid-template-columns 0.4s cubic-bezier(0.4, 0, 0.2, 1), gap 0.3s ease;
}
/* Sections */
.section {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(100, 149, 237, 0.1);
border-radius: 12px;
padding: 36px;
margin-bottom: 28px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeInUp 0.6s ease-out backwards;
}
.section:nth-child(1) { animation-delay: 0.2s; }
.section:nth-child(2) { animation-delay: 0.3s; }
.section:nth-child(3) { animation-delay: 0.4s; }
.section:nth-child(4) { animation-delay: 0.5s; }
.section:hover {
border-color: rgba(100, 149, 237, 0.4);
box-shadow: 0 12px 40px rgba(100, 149, 237, 0.2);
filter: brightness(1.02);
transform: translateY(-3px);
}
.section h2 {
color: #fff;
font-size: 1.3em;
margin-bottom: 20px;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.section:hover h2 {
color: #6495ed;
transform: translateX(4px) scale(1.02);
text-shadow: 0 0 15px rgba(100, 149, 237, 0.3);
}
.section h3 {
color: #94a3b8;
font-size: 0.9em;
margin-bottom: 12px;
margin-top: 20px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: color 0.2s ease;
}
.section:hover h3 {
color: #cbd5e1;
}
/* Form Elements */
.form-group {
margin-bottom: 20px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.form-group:hover {
transform: translateX(2px) scale(1.005);
background: rgba(100, 149, 237, 0.02);
border-radius: 8px;
}
.form-group:has(select) {
z-index: 100;
}
.form-group:hover {
opacity: 1;
}
label {
display: block;
color: #cbd5e1;
font-size: 0.9em;
margin-bottom: 6px;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.form-group:hover label {
color: #e2e8f0;
filter: brightness(1.1);
transform: scale(1.02) translateX(2px);
}
select, input[type="number"] {
width: 100%;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(100, 149, 237, 0.2);
border-radius: 6px;
color: #e2e8f0;
font-size: 0.95em;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
isolation: isolate;
}
input[type="number"]:hover {
border-color: rgba(100, 149, 237, 0.5);
box-shadow: 0 4px 12px rgba(100, 149, 237, 0.2);
filter: brightness(1.05);
transform: translateY(-1px);
}
input[type="number"]:focus {
outline: none;
border-color: rgba(100, 149, 237, 0.6);
background: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 3px rgba(100, 149, 237, 0.1);
}
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
cursor: pointer;
}
select:hover {
border-color: rgba(100, 149, 237, 0.4);
}
select:focus {
outline: none;
border-color: rgba(100, 149, 237, 0.6);
background: rgba(0, 0, 0, 0.4);
}
select option {
background: #1e293b;
color: #e2e8f0;
padding: 10px;
}
.custom-select{position:relative;width:100%;isolation:isolate}
.custom-select select{display:none}
.select-selected{background:rgba(0,0,0,.3);border:1px solid rgba(100,149,237,.2);border-radius:6px;padding:10px 36px 10px 12px;color:#e2e8f0;cursor:pointer;transition:all .3s cubic-bezier(.4,0,.2,1);position:relative;user-select:none;font-size:.95em;z-index:1;isolation:isolate}
.select-selected:after{content:'';position:absolute;top:50%;right:12px;width:0;height:0;border:5px solid transparent;border-top-color:#94a3b8;transform:translateY(-30%);transition:transform .3s ease}
.select-selected.select-arrow-active:after{transform:translateY(-50%) rotate(180deg);border-top-color:#6495ed}
.select-selected:hover{border-color:rgba(100,149,237,.6);background:rgba(0,0,0,.4);box-shadow:0 4px 12px rgba(100,149,237,.2);filter:brightness(1.05);transform:translateY(-1px)}
.select-items{position:absolute;background:#1e293b;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(100,149,237,.3);border-radius:8px;top:calc(100% + 4px);left:0;right:0;z-index:10000;max-height:0;overflow:hidden;opacity:0;visibility:hidden;transform:translateY(-15px) scale(0.95);transform-origin:top center;transition:max-height 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),opacity 0.3s ease,transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),visibility 0s 0.4s;box-shadow:0 10px 40px rgba(0,0,0,.5);pointer-events:none}
.select-items.show{max-height:300px;opacity:1;visibility:visible;transform:translateY(0) scale(1);overflow-y:auto;transition-delay:0s;pointer-events:auto}
.select-items div{padding:12px 16px;color:#cbd5e1;cursor:pointer;transition:all .2s ease;border-left:3px solid transparent;font-size:.95em;opacity:0;transform:translateX(-10px)}
.select-items.show div{opacity:1;transform:translateX(0)}
.select-items.show div:nth-child(1){transition-delay:.05s}
.select-items.show div:nth-child(2){transition-delay:.08s}
.select-items.show div:nth-child(3){transition-delay:.11s}
.select-items.show div:nth-child(4){transition-delay:.14s}
.select-items.show div:nth-child(5){transition-delay:.17s}
.select-items div:hover{background:rgba(100,149,237,.15);color:#fff;border-left-color:#6495ed;padding-left:20px}
.select-items div.same-as-selected{background:rgba(100,149,237,.2);color:#6495ed;font-weight:600;border-left-color:#6495ed}
.select-items::-webkit-scrollbar{width:6px}
.select-items::-webkit-scrollbar-track{background:rgba(0,0,0,.2);border-radius:3px}
.select-items::-webkit-scrollbar-thumb{background:rgba(100,149,237,.4);border-radius:3px}
.select-items::-webkit-scrollbar-thumb:hover{background:rgba(100,149,237,.6)}
.select-hide{display:none}
/* Checkbox */
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
transition: all 0.2s ease;
}
.checkbox-group:hover {
filter: brightness(1.1);
transform: translateX(3px);
}
input[type="checkbox"] {
width: 22px;
height: 22px;
cursor: pointer;
position: relative;
appearance: none;
background: rgba(0, 0, 0, 0.3);
border: 2px solid rgba(100, 149, 237, 0.3);
border-radius: 6px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
overflow: hidden;
}
input[type="checkbox"]::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transform: translate(-50%, -50%);
transition: width 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
height 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
input[type="checkbox"]:checked::before {
width: 150%;
height: 150%;
}
input[type="checkbox"]:hover {
border-color: rgba(100, 149, 237, 0.6);
background: rgba(100, 149, 237, 0.1);
transform: scale(1.05);
}
input[type="checkbox"]:active {
transform: scale(0.95);
}
input[type="checkbox"]:checked {
border-color: #667eea;
background: transparent;
}
input[type="checkbox"]:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
color: white;
font-size: 14px;
font-weight: bold;
z-index: 1;
animation: checkmarkPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s forwards;
}
@keyframes checkmarkPop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0) rotate(-45deg);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.3) rotate(10deg);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1) rotate(0deg);
}
}
input[type="checkbox"]:focus {
box-shadow: 0 0 0 3px rgba(100, 149, 237, 0.2);
}
.checkbox-group label {
margin-bottom: 0;
cursor: pointer;
transition: color 0.2s ease;
}
.checkbox-group:hover label {
color: #fff;
}
/* Button */
.btn {
width: 100%;
padding: 14px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 8px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-top: 8px;
position: relative;
overflow: hidden;
isolation: isolate;
}
.btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s ease;
}
.btn:hover::before {
left: 100%;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(102, 126, 234, 0.4);
filter: brightness(1.1);
animation: pulse 2s ease-in-out infinite;
}
.btn:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 5px 10px rgba(102, 126, 234, 0.2);
filter: brightness(0.95);
animation: none;
}
/* Grid Layout */
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
transition: grid-template-columns 0.3s ease, gap 0.3s ease;
}
.row .form-group {
margin-bottom: 0;
}
/* Preview */
.preview-container {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(100, 149, 237, 0.1);
border-radius: 12px;
padding: 36px;
height: fit-content;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeInUp 0.6s ease-out 0.3s backwards;
}
.preview-container:hover {
border-color: rgba(100, 149, 237, 0.4);
box-shadow: 0 12px 40px rgba(100, 149, 237, 0.2);
filter: brightness(1.02);
transform: translateY(-3px);
}
.preview-container h2 {
margin-bottom: 20px;
color: #fff;
font-size: 1.3em;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.preview-container:hover h2 {
color: #6495ed;
transform: translateX(4px) scale(1.02);
text-shadow: 0 0 15px rgba(100, 149, 237, 0.3);
}
.preview-container.updating {
border-color: rgba(100, 149, 237, 0.4);
}
#preview {
width: 100%;
height: 600px;
border: 1px solid rgba(100, 149, 237, 0.2);
border-radius: 8px;
background: #1e293b;
display: block;
cursor: grab;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
#preview:hover {
border-color: rgba(100, 149, 237, 0.5);
}
#preview:active {
cursor: grabbing;
}
#previewStats {
margin-top: 12px;
padding: 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
text-align: center;
font-size: 0.95em;
color: #94a3b8;
transition: all 0.3s ease;
}
#previewStats:hover {
background: rgba(0, 0, 0, 0.3);
color: #e2e8f0;
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(10, 15, 30, 0.95);
backdrop-filter: blur(8px);
display: none;
justify-content: center;
align-items: center;
z-index: 9999;
animation: fadeIn 0.3s ease;
}
.loading-overlay.active {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.loading-content {
text-align: center;
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.spinner {
width: 60px;
height: 60px;
border: 4px solid rgba(100, 149, 237, 0.2);
border-top-color: #6495ed;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 24px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 1.2em;
color: #fff;
font-weight: 600;
margin-bottom: 8px;
}
.loading-subtext {
font-size: 0.95em;
color: #94a3b8;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 4px 24px rgba(100, 149, 237, 0.3);
}
50% {
box-shadow: 0 4px 32px rgba(100, 149, 237, 0.5);
}
}
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.5;
}
100% {
transform: scale(2);
opacity: 0;
}
}
/* Responsive Design */
@media (max-width: 1400px) {
.main-layout {
grid-template-columns: 350px 1fr;
}
}
@media (max-width: 1200px) {
.main-layout {
grid-template-columns: 1fr;
}
.config-sidebar {
order: 2;
}
.preview-container {
order: 1;
}
#preview {
height: 400px;
}
}
@media (max-width: 768px) {
body {
padding: 20px 16px;
}
h1 {
font-size: 2em;
margin-bottom: 12px;
}
.subtitle {
font-size: 0.95em;
margin-bottom: 28px;
}
.section {
padding: 24px;
margin-bottom: 20px;
}
.preview-container {
padding: 24px;
}
.row {
grid-template-columns: 1fr;
gap: 16px;
}
#preview {
height: 400px;
cursor: default;
}
.form-group {
margin-bottom: 16px;
}
label {
font-size: 0.9em;
margin-bottom: 8px;
}
select, input[type="number"] {
font-size: 16px;
padding: 12px 14px;
}
.btn {
padding: 14px 24px;
font-size: 1em;
}
.loading-text {
font-size: 1.1em;
}
.loading-subtext {
font-size: 0.9em;
}
}
@media (max-width: 480px) {
body {
padding: 16px 12px;
}
h1 {
font-size: 1.75em;
}
.section {
padding: 20px;
border-radius: 10px;
}
.preview-container {
padding: 20px;
}
.section h2 {
font-size: 1.2em;
margin-bottom: 20px;
}
.section h3 {
font-size: 0.85em;
}
#preview {
height: 350px;
}
.spinner {
width: 50px;
height: 50px;
}
}
/* Landscape orientation for mobile */
@media (max-width: 768px) and (orientation: landscape) {
.main-layout {
grid-template-columns: 300px 1fr;
}
.config-sidebar {
order: 1;
}
.preview-container {
order: 2;
}
#preview {
height: 350px;
}
}
/* Touch device optimizations */
@media (hover: none) and (pointer: coarse) {
select, input[type="number"], .btn {
min-height: 48px;
font-size: 16px;
}
.checkbox-group input[type="checkbox"] {
width: 28px;
height: 28px;
}
.checkbox-group label {
padding-left: 12px;
}
.form-group:hover {
transform: none;
background: transparent;
}
input[type="number"]:hover,
select:hover,
.btn:hover {
transform: none;
filter: none;
}
.btn:active {
transform: scale(0.97);
}
#preview {
cursor: default;
touch-action: pan-x pan-y pinch-zoom;
}
}
/* Busbars */
#busbarList {
margin-bottom: 8px;
}
.busbar-empty {
color: #94a3b8;
font-size: 0.85em;
font-style: italic;
text-align: center;
padding: 12px 8px;
border: 1px dashed rgba(100, 149, 237, 0.2);
border-radius: 6px;
}
.busbar-row {
padding: 8px 10px;
border: 1px solid rgba(100, 149, 237, 0.15);
border-radius: 8px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(0, 0, 0, 0.2);
}
.busbar-row:hover {
border-color: rgba(100, 149, 237, 0.35);
background: rgba(0, 0, 0, 0.3);
}
.busbar-row.active {
border-color: #6495ed;
box-shadow: 0 0 0 1px rgba(100, 149, 237, 0.4), 0 4px 12px rgba(100, 149, 237, 0.15);
background: rgba(100, 149, 237, 0.08);
}
.busbar-header {
display: flex;
align-items: center;
gap: 8px;
}
.busbar-swatch {
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.busbar-name {
flex: 1;
min-width: 0;
padding: 4px 6px;
background: transparent;
border: 1px solid transparent;
color: #e2e8f0;
font-size: 0.9em;
border-radius: 4px;
}
.busbar-name:hover {
border-color: rgba(100, 149, 237, 0.25);
}
.busbar-name:focus {
outline: none;
border-color: rgba(100, 149, 237, 0.6);
background: rgba(0, 0, 0, 0.3);
}
.busbar-del {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 1.25em;
padding: 0 6px;
line-height: 1;
border-radius: 4px;
transition: all 0.15s ease;
}
.busbar-del:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.15);
}
.busbar-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
padding-left: 24px;
font-size: 0.8em;
color: #94a3b8;
}
.busbar-thickness-label {
display: flex;
align-items: center;
gap: 6px;
color: #94a3b8;
font-size: 0.95em;
cursor: text;
}
.busbar-thickness {
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-thickness:focus {
outline: none;
border-color: rgba(100, 149, 237, 0.6);
}
.busbar-blocked {
margin-top: 6px;
padding: 6px 8px;
font-size: 0.8em;
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
border-radius: 4px;
}
.btn-secondary {
width: 100%;
padding: 10px 16px;
background: rgba(100, 149, 237, 0.12);
border: 1px solid rgba(100, 149, 237, 0.3);
color: #e2e8f0;
border-radius: 8px;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
transition: all 0.2s ease;
margin-bottom: 12px;
}
.btn-secondary:hover {
background: rgba(100, 149, 237, 0.22);
border-color: rgba(100, 149, 237, 0.5);
transform: translateY(-1px);
}
.btn-secondary:active {
transform: translateY(0);
}

18
vendor/opencascade.wasm.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
vendor/opencascade.wasm.wasm vendored Normal file

Binary file not shown.