diff --git a/src/busbar-geometry.js b/src/busbar-geometry.js index c67fde0..ca07f98 100644 --- a/src/busbar-geometry.js +++ b/src/busbar-geometry.js @@ -176,6 +176,27 @@ function inferHorizontalPitch(positions) { return Number.isFinite(pitch) ? pitch : 0; } +function inferVerticalPitch(positions) { + const epsilon = 1e-3; + const cols = new Map(); + for (const pos of positions) { + const key = pos[0].toFixed(4); + if (!cols.has(key)) cols.set(key, []); + cols.get(key).push(pos[1]); + } + + let pitch = Infinity; + for (const colYs of cols.values()) { + colYs.sort((a, b) => a - b); + for (let index = 1; index < colYs.length; index++) { + const delta = colYs[index] - colYs[index - 1]; + if (delta > epsilon) pitch = Math.min(pitch, delta); + } + } + + return Number.isFinite(pitch) ? pitch : 0; +} + function computeBoundaryRoundoverFeatures(cellIndices, positions, padRadius) { if (cellIndices.length < 2) { return { extraPads: [], extraSegments: [] }; @@ -249,7 +270,11 @@ function computeBoundaryRoundoverFeatures(cellIndices, positions, padRadius) { return { extraPads, extraSegments }; } -function computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapLength) { +function computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapLength, cellRadius, spacing) { + if (layoutType === 'vertical') { + return computeEdgeOverlapFeaturesVertical(cellIndices, positions, overlapLength, cellRadius, spacing); + } + if (cellIndices.length < 2) { return { extraPads: [], extraSegments: [] }; } @@ -374,6 +399,204 @@ function computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapL return { extraPads, extraSegments }; } +function computeBoundaryRoundoverFeaturesVertical(cellIndices, positions, padRadius) { + if (cellIndices.length < 2) { + return { extraPads: [], extraSegments: [] }; + } + + const selected = cellIndices + .map((index) => ({ index, pos: positions[index] })) + .filter((entry) => Array.isArray(entry.pos) && entry.pos.length >= 2); + if (selected.length !== cellIndices.length) { + return { extraPads: [], extraSegments: [] }; + } + + const epsilon = 1e-3; + const verticalPitch = inferVerticalPitch(positions); + if (verticalPitch <= epsilon) { + return { extraPads: [], extraSegments: [] }; + } + + const yTolerance = Math.max(0.5, verticalPitch * 0.25); + const xTolerance = 1e-3; + const hasVerticalNeighbor = (entry, direction) => positions.some((pos) => { + if (Math.abs(pos[0] - entry.pos[0]) > xTolerance) return false; + const deltaY = pos[1] - entry.pos[1]; + if (direction === 'top' && deltaY >= -epsilon) return false; + if (direction === 'bottom' && deltaY <= epsilon) return false; + return Math.abs(Math.abs(deltaY) - verticalPitch) <= yTolerance; + }); + + const cols = new Map(); + for (const entry of selected) { + const key = entry.pos[0].toFixed(4); + if (!cols.has(key)) cols.set(key, []); + cols.get(key).push(entry); + } + + const topBoundary = []; + const bottomBoundary = []; + for (const colEntries of cols.values()) { + colEntries.sort((a, b) => a.pos[1] - b.pos[1]); + topBoundary.push(colEntries[0]); + bottomBoundary.push(colEntries[colEntries.length - 1]); + } + + const topExposed = topBoundary.filter((entry) => !hasVerticalNeighbor(entry, 'top')); + const bottomExposed = bottomBoundary.filter((entry) => !hasVerticalNeighbor(entry, 'bottom')); + + const extraPads = []; + const extraSegments = []; + const fillRadius = Math.max(0.2, padRadius * 0.55); + const outwardShift = Math.max(0.2, padRadius * 0.22); + + const addSideRoundovers = (entries, side) => { + const direction = side === 'top' ? -1 : 1; + const ordered = entries.slice().sort((a, b) => a.pos[0] - b.pos[0]); + for (let index = 0; index < ordered.length - 1; index++) { + const left = ordered[index]; + const right = ordered[index + 1]; + const fillKey = `boundary_round_${side}_${index}`; + const fillPos = [ + (left.pos[0] + right.pos[0]) / 2, + (left.pos[1] + right.pos[1]) / 2 + direction * outwardShift, + ]; + extraPads.push({ key: fillKey, pos: fillPos, radius: fillRadius }); + extraSegments.push({ from: fillPos, to: left.pos, fromKey: fillKey, toKey: `c${left.index}`, radius: fillRadius }); + extraSegments.push({ from: fillPos, to: right.pos, fromKey: fillKey, toKey: `c${right.index}`, radius: fillRadius }); + } + }; + + addSideRoundovers(topExposed, 'top'); + addSideRoundovers(bottomExposed, 'bottom'); + + return { extraPads, extraSegments }; +} + +function computeEdgeOverlapFeaturesVertical(cellIndices, positions, overlapLength, cellRadius, spacing) { + if (cellIndices.length < 2) { + return { extraPads: [], extraSegments: [] }; + } + + const selected = cellIndices + .map((index) => ({ index, pos: positions[index] })) + .filter((entry) => Array.isArray(entry.pos) && entry.pos.length >= 2); + if (selected.length !== cellIndices.length) { + return { extraPads: [], extraSegments: [] }; + } + + // Measure the X pitch between adjacent columns in the full layout. + const uniqueColXs = [...new Set(positions.map(p => Math.round(p[0] * 10)))] + .sort((a, b) => a - b); + let colPitch = Infinity; + for (let i = 1; i < uniqueColXs.length; i++) { + const d = (uniqueColXs[i] - uniqueColXs[i - 1]) / 10; + if (d > 1e-3) colPitch = Math.min(colPitch, d); + } + if (!Number.isFinite(colPitch) || colPitch <= 1e-3) { + return { extraPads: [], extraSegments: [] }; + } + const colTolerance = colPitch * 0.3; + + // Group selected cells by column (keyed by rounded X*10). + const colMap = new Map(); + for (const entry of selected) { + const key = Math.round(entry.pos[0] * 10); + if (!colMap.has(key)) colMap.set(key, []); + colMap.get(key).push(entry); + } + + const sortedColKeys = [...colMap.keys()].sort((a, b) => a - b); + const leftColX = sortedColKeys[0] / 10; + const rightColX = sortedColKeys[sortedColKeys.length - 1] / 10; + + // Count how many boundary-column cells have no full-layout column neighbor. + const leftColCells = colMap.get(sortedColKeys[0]); + const rightColCells = colMap.get(sortedColKeys[sortedColKeys.length - 1]); + + const countExposed = (colCells, side) => colCells.filter((entry) => { + const neighborX = side === 'left' ? leftColX - colPitch : rightColX + colPitch; + return !positions.some((p) => Math.abs(p[0] - neighborX) < colTolerance); + }).length; + + const leftExposedCount = countExposed(leftColCells, 'left'); + const rightExposedCount = countExposed(rightColCells, 'right'); + + // Skip if the busbar is only a single column — a single-column spine that + // runs straight through all pads adds no useful material above what the + // pad-and-edge geometry already provides. + // (We still proceed when there are multiple columns.) + let chosenSide, boundaryCells, boundaryColX; + if (leftExposedCount > rightExposedCount) { + chosenSide = 'left'; + boundaryCells = leftColCells; + boundaryColX = leftColX; + } else if (rightExposedCount > leftExposedCount) { + chosenSide = 'right'; + boundaryCells = rightColCells; + boundaryColX = rightColX; + } else if (leftExposedCount > 0) { + // Tie: prefer left. + chosenSide = 'left'; + boundaryCells = leftColCells; + boundaryColX = leftColX; + } else { + return { extraPads: [], extraSegments: [] }; + } + + const extension = Number.isFinite(Number(overlapLength)) && Number(overlapLength) > 0 + ? Number(overlapLength) + : 10; + + // Place the spine relative to the pack boundary (not the cell centre) so the + // overlap tab is clearly visible outside the pack rectangle. + const packLeft = Math.min(...positions.map(p => p[0])) - cellRadius - spacing; + const packRight = Math.max(...positions.map(p => p[0])) + cellRadius + spacing; + const spineX = chosenSide === 'left' + ? packLeft - extension + : packRight + extension; + + const extraPads = []; + const extraSegments = []; + const sortedBoundaryCells = boundaryCells.slice().sort((a, b) => a.pos[1] - b.pos[1]); + + const overlapPads = sortedBoundaryCells.map((entry, index) => { + const key = `edge_overlap_${index}`; + const overlapPos = [spineX, entry.pos[1]]; + extraPads.push({ key, pos: overlapPos }); + extraSegments.push({ from: overlapPos, to: entry.pos, fromKey: key, toKey: `c${entry.index}` }); + return { key, pos: overlapPos }; + }); + + // Connect adjacent spine pads with a vertical segment and add fill pads. + for (let index = 0; index < overlapPads.length - 1; index++) { + const topPad = overlapPads[index]; + const bottomPad = overlapPads[index + 1]; + const topCell = sortedBoundaryCells[index]; + const bottomCell = sortedBoundaryCells[index + 1]; + + extraSegments.push({ + from: topPad.pos, + to: bottomPad.pos, + fromKey: topPad.key, + toKey: bottomPad.key, + }); + + const fillKey = `edge_overlap_fill_${index}`; + const fillPos = [ + (topPad.pos[0] + bottomPad.pos[0] + topCell.pos[0] + bottomCell.pos[0]) / 4, + (topPad.pos[1] + bottomPad.pos[1] + topCell.pos[1] + bottomCell.pos[1]) / 4, + ]; + extraPads.push({ key: fillKey, pos: fillPos }); + extraSegments.push({ from: fillPos, to: topPad.pos, fromKey: fillKey, toKey: topPad.key }); + extraSegments.push({ from: fillPos, to: bottomPad.pos, fromKey: fillKey, toKey: bottomPad.key }); + extraSegments.push({ from: fillPos, to: topCell.pos, fromKey: fillKey, toKey: `c${topCell.index}` }); + extraSegments.push({ from: fillPos, to: bottomCell.pos, fromKey: fillKey, toKey: `c${bottomCell.index}` }); + } + + return { extraPads, extraSegments }; +} + function computeCellCutouts(cellIndices, positions, cellCutoutEnabled) { if (cellCutoutEnabled !== true) return []; @@ -439,9 +662,11 @@ export function computeBusbarGeometry(cellIndices, positions, cellRadius, padRad }); const overlapFeatures = overlapEnabled - ? computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapSize) + ? computeEdgeOverlapFeatures(cellIndices, positions, layoutType, overlapSize, cellRadius, spacing) : { extraPads: [], extraSegments: [] }; - const roundoverFeatures = computeBoundaryRoundoverFeatures(cellIndices, positions, padRadius); + const roundoverFeatures = layoutType === 'vertical' + ? computeBoundaryRoundoverFeaturesVertical(cellIndices, positions, padRadius) + : computeBoundaryRoundoverFeatures(cellIndices, positions, padRadius); const cutouts = computeCellCutouts(cellIndices, positions, cellCutoutEnabled); return { diff --git a/src/main.js b/src/main.js index 77bd311..e8de34a 100644 --- a/src/main.js +++ b/src/main.js @@ -19,7 +19,6 @@ function scaleCanvasById(id) { const canvas = document.getElementById(id); if (!canvas) return; const dpr = window.devicePixelRatio || 1; - // Let CSS (width:100%, height:720px) control the display size. // Only update the drawing buffer to match the current CSS-rendered size × DPR. const rect = canvas.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; @@ -672,6 +671,22 @@ async function initializeApp() { await initOC(); + // Keep canvas drawing buffers in sync whenever their CSS size changes + // (e.g. window resize, flex layout settling, face-filter toggles). + const canvasResizeObserver = new ResizeObserver(() => { + scaleCanvasForDPI(); + updatePreview(false); + }); + const previewCanvas = document.getElementById('preview'); + const previewBottomCanvas = document.getElementById('preview-bottom'); + if (previewCanvas) canvasResizeObserver.observe(previewCanvas); + if (previewBottomCanvas) canvasResizeObserver.observe(previewBottomCanvas); + + window.addEventListener('resize', () => { + scaleCanvasForDPI(); + updatePreview(false); + }); + setTimeout(() => { // Force a fresh render after both OC init and config load are complete. // This ensures viewTransform and geometries are in sync with loaded busbars. diff --git a/styles/main.css b/styles/main.css index f690b73..06238c8 100644 --- a/styles/main.css +++ b/styles/main.css @@ -10,26 +10,29 @@ body { min-height: 100vh; color: #e2e8f0; line-height: 1.6; - padding: 48px 24px; + padding: 12px 24px; } .container { max-width: 1600px; margin: 0 auto; + height: 100%; + display: flex; + flex-direction: column; transition: max-width 0.3s ease, padding 0.3s ease; } /* Header */ .header-row { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 20px; - margin-bottom: 10px; + gap: 12px; + margin-bottom: 4px; } h1 { - font-size: 2.5em; + font-size: 1.6em; font-weight: 700; color: #fff; margin-bottom: 0; @@ -45,8 +48,8 @@ h1:hover { .subtitle { color: #94a3b8; - font-size: 1em; - margin-bottom: 32px; + font-size: 0.88em; + margin-bottom: 6px; transition: color 0.3s ease; animation: fadeInUp 0.6s ease-out 0.1s both; } @@ -100,8 +103,8 @@ h1:hover { align-items: center; justify-content: center; gap: 10px; - margin: 0 auto 18px; - padding: 10px 18px; + margin: 0 auto 8px; + padding: 6px 14px; max-width: 700px; background: rgba(234, 179, 8, 0.1); border: 1px solid rgba(234, 179, 8, 0.45); @@ -163,6 +166,9 @@ h1:hover { display: grid; grid-template-columns: 460px 1fr; gap: 28px; + flex: 1; + min-height: 0; + align-items: stretch; transition: grid-template-columns 0.4s cubic-bezier(0.4, 0, 0.2, 1), gap 0.3s ease; } @@ -191,6 +197,8 @@ h1:hover { border: 1px solid rgba(100, 149, 237, 0.1); border-radius: 14px; padding: 20px 20px 24px; + overflow-y: auto; + min-height: 0; transition: border-color 0.3s ease, box-shadow 0.3s ease; } @@ -400,6 +408,9 @@ h1:hover { .preview-container { padding: 28px; + display: flex; + flex-direction: column; + min-height: 0; } .section:hover { @@ -763,12 +774,15 @@ input[type="checkbox"]:focus { .previews-row { display: flex; gap: 12px; - align-items: flex-start; + flex: 1; + min-height: 0; + align-items: stretch; } .preview-face-wrap { flex: 1; min-width: 0; + min-height: 0; display: flex; flex-direction: column; gap: 6px; @@ -791,13 +805,14 @@ input[type="checkbox"]:focus { #preview, #preview-bottom { width: 100%; - height: 720px; + height: min(calc(100vh - 280px), 580px); + min-height: 380px; border: 1px solid rgba(100, 149, 237, 0.2); border-radius: 10px; background: #1e293b; display: block; cursor: grab; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1); } #preview:hover,