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:
126
index.html
Normal file
126
index.html
Normal 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
495
src/app.js
Normal 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
130
src/busbar-geometry.js
Normal 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
57
src/busbar-model.js
Normal 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
102
src/busbar-preview.js
Normal 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
67
src/busbar-ui.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { busbarStore } from './busbars.js';
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, ch => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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
87
src/busbars.js
Normal 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
76
src/layouts.js
Normal 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
271
src/main.js
Normal 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
401
src/model.js
Normal 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
23
src/oc.js
Normal 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
481
src/preview.js
Normal 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
19
src/state.js
Normal 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
32
src/step-export.js
Normal 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
94
src/ui.js
Normal 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
895
styles/main.css
Normal 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
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
BIN
vendor/opencascade.wasm.wasm
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user