From 29d76348aa8d3b1aa4cdb789f2ed2a63af830645 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Fri, 15 May 2026 22:24:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(sim):=20Phase=201d=20#3=20+=20#5=20?= =?UTF-8?q?=E2=80=94=20WASM=20pre-boot=20on=20mount=20+=20delete=20dead=20?= =?UTF-8?q?wire*=20utils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3: `start.ts` now kicks `scheduler.start()` (lazy-boot the WASM engine) right when the editor mounts. Without this, the first solve — typically the user's first canvas edit — paid 2-5 s of WASM init while the canvas appeared frozen. Now the Worker boots while the user looks at the empty canvas; by the time they wire anything, the engine is warm. #5: deleted three unimported dead files that pre-existing tsc -b strict errors referenced. Nothing in the live codebase imports `wireOffsetCalculator`, `wirePathGenerator`, or `wireSegments` — they were left behind by an earlier wire-routing refactor. Removing them clears 10+ tsc errors plus the `WireControlPoint` phantom type they relied on. Also cleaned up an unused import in `capacitor-charge-transient.test.ts` (leftover from F2). 1461 tests pass, vite build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../capacitor-charge-transient.test.ts | 1 - frontend/src/simulation/spice/start.ts | 8 + frontend/src/utils/wireOffsetCalculator.ts | 291 ------------------ frontend/src/utils/wirePathGenerator.ts | 101 ------ frontend/src/utils/wireSegments.ts | 245 --------------- 5 files changed, 8 insertions(+), 638 deletions(-) delete mode 100644 frontend/src/utils/wireOffsetCalculator.ts delete mode 100644 frontend/src/utils/wirePathGenerator.ts delete mode 100644 frontend/src/utils/wireSegments.ts diff --git a/frontend/src/__tests__/capacitor-charge-transient.test.ts b/frontend/src/__tests__/capacitor-charge-transient.test.ts index a63c40aa..8b0918c7 100644 --- a/frontend/src/__tests__/capacitor-charge-transient.test.ts +++ b/frontend/src/__tests__/capacitor-charge-transient.test.ts @@ -11,7 +11,6 @@ */ import { describe, it, expect } from 'vitest'; import { buildInputFromStore } from '../simulation/spice/storeAdapter'; -import type { StoreSnapshot } from '../simulation/spice/storeAdapter'; import type { PinSourceState } from '../simulation/spice/types'; describe('storeAdapter — MCU-driven capacitor step response', () => { diff --git a/frontend/src/simulation/spice/start.ts b/frontend/src/simulation/spice/start.ts index 52b94093..1f84b38a 100644 --- a/frontend/src/simulation/spice/start.ts +++ b/frontend/src/simulation/spice/start.ts @@ -70,6 +70,14 @@ export function startSimulation(): () => void { ), }, ); + // Phase 1d #3 — pre-boot the WASM engine the moment the editor + // mounts. Without this, the first solve (typically the user's + // first canvas edit) pays the full WASM init cost (~2-5 s) and the + // canvas appears frozen. By kicking init now, the Worker boots + // while the user looks at the empty canvas; by the time they wire + // anything, the engine is warm. + void getMixedModeScheduler().start(); + const unsubService = service.start(); const unsubAdc = connectAnalogInputsToMcu(); const unsubEdges = connectMcuEdgesToService(service); diff --git a/frontend/src/utils/wireOffsetCalculator.ts b/frontend/src/utils/wireOffsetCalculator.ts deleted file mode 100644 index cf81d22c..00000000 --- a/frontend/src/utils/wireOffsetCalculator.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Wire Offset Calculator - * - * Automatically calculates visual offsets for overlapping wires to prevent - * them from rendering on top of each other (similar to Fritzing/TinkerCAD). - * - * Algorithm: - * 1. Detect wire segments that are parallel and overlapping - * 2. Group overlapping segments - * 3. Apply perpendicular offset to each wire in the group - * 4. Distribute offsets evenly around the center line - */ - -import type { Wire } from '../types/wire'; - -export const WIRE_SPACING = 6; // Pixels between parallel wires -const OVERLAP_TOLERANCE = 5; // Pixels tolerance for considering wires as overlapping - -/** - * Represents a wire segment (portion of a wire between two bends) - */ -interface WireSegment { - wireId: string; - isVertical: boolean; - start: { x: number; y: number }; - end: { x: number; y: number }; - centerLine: number; // X position for vertical, Y position for horizontal -} - -/** - * Group of overlapping wire segments - */ -interface SegmentGroup { - segments: WireSegment[]; - isVertical: boolean; - centerLine: number; - overlapStart: number; // Start of overlapping region - overlapEnd: number; // End of overlapping region -} - -/** - * Extract all segments from a wire's path - */ -function extractSegments(wire: Wire): WireSegment[] { - const segments: WireSegment[] = []; - - // Start point - let currentPoint = { x: wire.start.x, y: wire.start.y }; - - // Add segments through control points - if (wire.controlPoints && wire.controlPoints.length > 0) { - for (const controlPoint of wire.controlPoints) { - const nextPoint = { x: controlPoint.x, y: controlPoint.y }; - - // Determine if segment is vertical or horizontal - const isVertical = - Math.abs(nextPoint.x - currentPoint.x) < Math.abs(nextPoint.y - currentPoint.y); - const centerLine = isVertical ? currentPoint.x : currentPoint.y; - - segments.push({ - wireId: wire.id, - isVertical, - start: { ...currentPoint }, - end: { ...nextPoint }, - centerLine, - }); - - currentPoint = nextPoint; - } - } - - // Final segment to end point - const endPoint = { x: wire.end.x, y: wire.end.y }; - const isVertical = Math.abs(endPoint.x - currentPoint.x) < Math.abs(endPoint.y - currentPoint.y); - const centerLine = isVertical ? currentPoint.x : currentPoint.y; - - segments.push({ - wireId: wire.id, - isVertical, - start: { ...currentPoint }, - end: { ...endPoint }, - centerLine, - }); - - return segments; -} - -/** - * Check if two segments overlap - */ -function segmentsOverlap(seg1: WireSegment, seg2: WireSegment): boolean { - // Must be same orientation - if (seg1.isVertical !== seg2.isVertical) return false; - - // Must be on similar center line (within tolerance) - if (Math.abs(seg1.centerLine - seg2.centerLine) > OVERLAP_TOLERANCE) return false; - - // Check if ranges overlap - if (seg1.isVertical) { - // Vertical: check Y range overlap - const seg1MinY = Math.min(seg1.start.y, seg1.end.y); - const seg1MaxY = Math.max(seg1.start.y, seg1.end.y); - const seg2MinY = Math.min(seg2.start.y, seg2.end.y); - const seg2MaxY = Math.max(seg2.start.y, seg2.end.y); - - return !(seg1MaxY < seg2MinY || seg2MaxY < seg1MinY); - } else { - // Horizontal: check X range overlap - const seg1MinX = Math.min(seg1.start.x, seg1.end.x); - const seg1MaxX = Math.max(seg1.start.x, seg1.end.x); - const seg2MinX = Math.min(seg2.start.x, seg2.end.x); - const seg2MaxX = Math.max(seg2.start.x, seg2.end.x); - - return !(seg1MaxX < seg2MinX || seg2MaxX < seg1MinX); - } -} - -/** - * Group overlapping segments - */ -function groupOverlappingSegments(segments: WireSegment[]): SegmentGroup[] { - const groups: SegmentGroup[] = []; - const processed = new Set(); - - for (let i = 0; i < segments.length; i++) { - const seg1 = segments[i]; - const key1 = `${seg1.wireId}-${i}`; - - if (processed.has(key1)) continue; - - // Find all segments that overlap with seg1 - const group: WireSegment[] = [seg1]; - processed.add(key1); - - for (let j = i + 1; j < segments.length; j++) { - const seg2 = segments[j]; - const key2 = `${seg2.wireId}-${j}`; - - if (processed.has(key2)) continue; - - // Check if seg2 overlaps with any segment in the current group - if (group.some((seg) => segmentsOverlap(seg, seg2))) { - group.push(seg2); - processed.add(key2); - } - } - - // Only create a group if there are at least 2 overlapping segments - if (group.length > 1) { - const isVertical = group[0].isVertical; - const centerLine = group.reduce((sum, seg) => sum + seg.centerLine, 0) / group.length; - - // Calculate overlap region - let overlapStart: number; - let overlapEnd: number; - - if (isVertical) { - overlapStart = Math.max(...group.map((seg) => Math.min(seg.start.y, seg.end.y))); - overlapEnd = Math.min(...group.map((seg) => Math.max(seg.start.y, seg.end.y))); - } else { - overlapStart = Math.max(...group.map((seg) => Math.min(seg.start.x, seg.end.x))); - overlapEnd = Math.min(...group.map((seg) => Math.max(seg.start.x, seg.end.x))); - } - - groups.push({ - segments: group, - isVertical, - centerLine, - overlapStart, - overlapEnd, - }); - } - } - - return groups; -} - -/** - * Calculate offset for each wire based on overlapping groups - */ -export function calculateWireOffsets(wires: Wire[]): Map { - const offsets = new Map(); - - // Initialize all offsets to 0 - wires.forEach((wire) => offsets.set(wire.id, 0)); - - // Extract all segments from all wires - const allSegments: WireSegment[] = []; - wires.forEach((wire) => { - allSegments.push(...extractSegments(wire)); - }); - - // Group overlapping segments - const groups = groupOverlappingSegments(allSegments); - - // Calculate offsets for each group - groups.forEach((group) => { - const numWires = group.segments.length; - - // Get unique wire IDs in this group - const wireIds = [...new Set(group.segments.map((seg) => seg.wireId))]; - - // Calculate offset for each wire - wireIds.forEach((wireId, index) => { - // Distribute offsets symmetrically around center - // For n wires: offsets are [-spacing*(n-1)/2, ..., 0, ..., +spacing*(n-1)/2] - const offset = (index - (numWires - 1) / 2) * WIRE_SPACING; - - // Store the maximum absolute offset for this wire - // (in case wire participates in multiple groups) - const currentOffset = offsets.get(wireId) || 0; - if (Math.abs(offset) > Math.abs(currentOffset)) { - offsets.set(wireId, offset); - } - }); - }); - - return offsets; -} - -/** - * Apply offset to wire points (perpendicular to wire direction). - * - * Instead of moving the endpoints (which would visually disconnect the wire from its - * pins), we keep the true pin positions fixed and insert short stub segments that - * travel from each pin to the offset path, forming an L-shaped attachment at both ends. - * - * Example (horizontal wire, offset = +6): - * Before: pin ────────────────── pin - * After: pin (stub) - * │ ──────────────── │ - * (stub) pin - */ -export function applyOffsetToWire(wire: Wire, offset: number): Wire { - if (offset === 0) return wire; - - // Determine primary direction from the first segment of the path - const firstControlOrEnd = - wire.controlPoints && wire.controlPoints.length > 0 ? wire.controlPoints[0] : wire.end; - - const isHorizontalFirst = - Math.abs(firstControlOrEnd.x - wire.start.x) >= Math.abs(firstControlOrEnd.y - wire.start.y); - - // True pin positions (never moved) - const pinStart = { x: wire.start.x, y: wire.start.y }; - const pinEnd = { x: wire.end.x, y: wire.end.y }; - - // Offset intermediate points perpendicular to the primary direction - const shiftedControlPoints = (wire.controlPoints || []).map((cp) => ({ - ...cp, - x: isHorizontalFirst ? cp.x : cp.x + offset, - y: isHorizontalFirst ? cp.y + offset : cp.y, - })); - - // Compute where the offset path actually starts/ends - // (the point on the parallel track immediately after the pin stub) - const offsetStart = isHorizontalFirst - ? { x: pinStart.x, y: pinStart.y + offset } - : { x: pinStart.x + offset, y: pinStart.y }; - - const offsetEnd = isHorizontalFirst - ? { x: pinEnd.x, y: pinEnd.y + offset } - : { x: pinEnd.x + offset, y: pinEnd.y }; - - // Build new control points: - // stub from pinStart → offsetStart, then the shifted intermediates, then stub from offsetEnd → pinEnd - // We only need to add extra stubs when they are non-zero length. - const newControlPoints: typeof wire.controlPoints = []; - - // Leading stub end-point (where the offset path begins) - if (offsetStart.x !== pinStart.x || offsetStart.y !== pinStart.y) { - newControlPoints.push({ id: `${wire.id}-stub-s`, ...offsetStart }); - } - - // Shifted original control points - for (const cp of shiftedControlPoints) { - newControlPoints.push(cp); - } - - // Trailing stub start-point (where the offset path rejoins the pin) - if (offsetEnd.x !== pinEnd.x || offsetEnd.y !== pinEnd.y) { - newControlPoints.push({ id: `${wire.id}-stub-e`, ...offsetEnd }); - } - - return { - ...wire, - start: { ...wire.start, x: pinStart.x, y: pinStart.y }, - end: { ...wire.end, x: pinEnd.x, y: pinEnd.y }, - controlPoints: newControlPoints, - }; -} diff --git a/frontend/src/utils/wirePathGenerator.ts b/frontend/src/utils/wirePathGenerator.ts deleted file mode 100644 index 820042ac..00000000 --- a/frontend/src/utils/wirePathGenerator.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Wire Path Generator - * - * Generates SVG path strings for wires with orthogonal routing (only horizontal/vertical segments). - * Phase 1: Simple L-shape - * Phase 2: Multi-segment with control points - * Phase 3: A* pathfinding integration - */ - -import type { Wire, WireControlPoint } from '../types/wire'; - -/** - * Generates an SVG path string for a wire. - * Routes wires using orthogonal paths (90-degree angles only). - * - * @param wire - The wire object containing endpoints and control points - * @returns SVG path string (e.g., "M 10 20 L 30 20 L 30 50") - */ -export function generateWirePath(wire: Wire): string { - const { start, end, controlPoints } = wire; - - if (controlPoints.length === 0) { - // Phase 1: Simple L-shape routing - return generateSimplePath(start.x, start.y, end.x, end.y); - } else { - // Phase 2: Multi-segment with control points - return generateMultiSegmentPath(start, controlPoints, end); - } -} - -/** - * Phase 1: Generates a simple L-shaped path between two points. - * Prioritizes horizontal-first routing (goes horizontal, then vertical, then horizontal). - * - * Pattern: - * Start → [horizontal] → midpoint → [vertical] → midpoint → [horizontal] → End - * - * @param x1 - Start X coordinate - * @param y1 - Start Y coordinate - * @param x2 - End X coordinate - * @param y2 - End Y coordinate - * @returns SVG path string - */ -function generateSimplePath(x1: number, y1: number, x2: number, y2: number): string { - // Calculate midpoint X (for L-shape bend) - const midX = x1 + (x2 - x1) / 2; - - // Create horizontal-first L-shape path - // Format: M x1,y1 L midX,y1 L midX,y2 L x2,y2 - return `M ${x1} ${y1} L ${midX} ${y1} L ${midX} ${y2} L ${x2} ${y2}`; -} - -/** - * Phase 2: Generates path with multiple control points. - * All segments are constrained to horizontal or vertical only (orthogonal routing). - * - * @param start - Start endpoint { x, y } - * @param controlPoints - Array of control points - * @param end - End endpoint { x, y } - * @returns SVG path string - */ -function generateMultiSegmentPath( - start: { x: number; y: number }, - controlPoints: WireControlPoint[], - end: { x: number; y: number }, -): string { - let path = `M ${start.x} ${start.y}`; - - // Control points already represent exact corners, so connect them sequentially - for (let i = 0; i < controlPoints.length; i++) { - const cp = controlPoints[i]; - path += ` L ${cp.x} ${cp.y}`; - } - - // Connect last control point to end - path += ` L ${end.x} ${end.y}`; - - return path; -} - -/** - * Calculates the total length of a wire path (useful for rendering and optimization). - * - * @param wire - The wire object - * @returns Total path length in pixels - */ -export function calculateWireLength(wire: Wire): number { - const { start, end, controlPoints } = wire; - - let totalLength = 0; - let prevPoint = start; - - for (const cp of controlPoints) { - totalLength += Math.abs(cp.x - prevPoint.x) + Math.abs(cp.y - prevPoint.y); - prevPoint = cp; - } - - totalLength += Math.abs(end.x - prevPoint.x) + Math.abs(end.y - prevPoint.y); - - return totalLength; -} diff --git a/frontend/src/utils/wireSegments.ts b/frontend/src/utils/wireSegments.ts deleted file mode 100644 index 6cd08cd1..00000000 --- a/frontend/src/utils/wireSegments.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Wire Segment Utilities - * - * Handles computation and manipulation of wire segments for interactive editing. - * Segments are the straight horizontal/vertical lines between path points. - */ - -import type { Wire, WireControlPoint } from '../types/wire'; - -export interface WireSegment { - id: string; - startPoint: { x: number; y: number }; - endPoint: { x: number; y: number }; - orientation: 'horizontal' | 'vertical'; - midPoint: { x: number; y: number }; - length: number; - startIndex: number; // Index in orthoPoints array - endIndex: number; // Index in orthoPoints array -} - -/** - * Get all path points (start + control points + end) - */ -export function getPathPoints(wire: Wire): Array<{ x: number; y: number }> { - const points: Array<{ x: number; y: number }> = []; - - points.push({ x: wire.start.x, y: wire.start.y }); - - for (const cp of wire.controlPoints) { - points.push({ x: cp.x, y: cp.y }); - } - - points.push({ x: wire.end.x, y: wire.end.y }); - - return points; -} - -/** - * Generate orthogonal path points from control points - * Converts diagonal connections to L-shapes (horizontal then vertical or vice versa) - */ -export function generateOrthogonalPoints( - points: Array<{ x: number; y: number }>, -): Array<{ x: number; y: number }> { - const result: Array<{ x: number; y: number }> = []; - - for (let i = 0; i < points.length - 1; i++) { - const current = points[i]; - const next = points[i + 1]; - - result.push(current); - - // If points are not aligned, add intermediate point - if (current.x !== next.x && current.y !== next.y) { - const dx = Math.abs(next.x - current.x); - const dy = Math.abs(next.y - current.y); - - if (dx > dy) { - // Go horizontal first - result.push({ x: next.x, y: current.y }); - } else { - // Go vertical first - result.push({ x: current.x, y: next.y }); - } - } - } - - result.push(points[points.length - 1]); - - return result; -} - -/** - * Compute all segments from a wire - */ -export function computeSegments(wire: Wire): WireSegment[] { - const pathPoints = getPathPoints(wire); - const orthoPoints = generateOrthogonalPoints(pathPoints); - const segments: WireSegment[] = []; - - for (let i = 0; i < orthoPoints.length - 1; i++) { - const start = orthoPoints[i]; - const end = orthoPoints[i + 1]; - - // Skip zero-length segments - if (start.x === end.x && start.y === end.y) continue; - - const orientation = start.y === end.y ? 'horizontal' : 'vertical'; - const length = - orientation === 'horizontal' ? Math.abs(end.x - start.x) : Math.abs(end.y - start.y); - - segments.push({ - id: `${wire.id}-seg-${i}`, - startPoint: start, - endPoint: end, - orientation, - midPoint: { - x: (start.x + end.x) / 2, - y: (start.y + end.y) / 2, - }, - length, - startIndex: i, - endIndex: i + 1, - }); - } - - return segments; -} - -/** - * Find which segment is under the cursor - */ -export function findSegmentUnderCursor( - segments: WireSegment[], - mouseX: number, - mouseY: number, - threshold: number = 8, // 8px tolerance -): WireSegment | null { - for (const segment of segments) { - if (segment.orientation === 'horizontal') { - const minX = Math.min(segment.startPoint.x, segment.endPoint.x); - const maxX = Math.max(segment.startPoint.x, segment.endPoint.x); - const lineY = segment.startPoint.y; - - if (mouseX >= minX && mouseX <= maxX && Math.abs(mouseY - lineY) <= threshold) { - return segment; - } - } else { - const minY = Math.min(segment.startPoint.y, segment.endPoint.y); - const maxY = Math.max(segment.startPoint.y, segment.endPoint.y); - const lineX = segment.startPoint.x; - - if (mouseY >= minY && mouseY <= maxY && Math.abs(mouseX - lineX) <= threshold) { - return segment; - } - } - } - - return null; -} - -/** - * Update orthogonal points when dragging a segment - */ -export function updateOrthogonalPointsForSegmentDrag( - orthoPoints: Array<{ x: number; y: number }>, - segment: WireSegment, - offset: number, -): Array<{ x: number; y: number }> { - const newPoints = orthoPoints.map((p) => ({ ...p })); - const { startIndex, endIndex, orientation } = segment; - - const newStart = { ...newPoints[startIndex] }; - const newEnd = { ...newPoints[endIndex] }; - - if (orientation === 'horizontal') { - newStart.y += offset; - newEnd.y += offset; - } else { - newStart.x += offset; - newEnd.x += offset; - } - - const resultPoints: Array<{ x: number; y: number }> = []; - - // Add points before the dragged segment - for (let i = 0; i < startIndex; i++) { - resultPoints.push(newPoints[i]); - } - - // If dragging the first segment, inject the original start pin to act as a stub anchor - if (startIndex === 0 && offset !== 0) { - resultPoints.push({ ...newPoints[0] }); - } - - // Add the dragged segment's end points - resultPoints.push(newStart); - resultPoints.push(newEnd); - - // If dragging the last segment, inject the original end pin to act as a stub anchor - if (endIndex === newPoints.length - 1 && offset !== 0) { - resultPoints.push({ ...newPoints[newPoints.length - 1] }); - } - - // Add points after the dragged segment - for (let i = endIndex + 1; i < newPoints.length; i++) { - resultPoints.push(newPoints[i]); - } - - return resultPoints; -} - -/** - * Convert orthogonal points back to control points - * Removes start/end points and intermediate points that are redundant - * - * IMPORTANT: The first and last orthoPoints should match the wire endpoints. - * We preserve ALL intermediate points that represent corners (direction changes). - */ -export function orthogonalPointsToControlPoints( - orthoPoints: Array<{ x: number; y: number }>, - _start: { x: number; y: number }, - _end: { x: number; y: number }, -): WireControlPoint[] { - if (orthoPoints.length < 2) { - return []; - } - - // Remove first and last points (those are start/end endpoints) - const innerPoints = orthoPoints.slice(1, -1); - - if (innerPoints.length === 0) { - return []; - } - - // Keep only corner points (where direction changes) - const controlPoints: WireControlPoint[] = []; - - for (let i = 0; i < innerPoints.length; i++) { - const current = innerPoints[i]; - // Get prev from orthoPoints (index i in innerPoints = index i+1 in orthoPoints) - const prev = orthoPoints[i]; // Previous point in orthoPoints - const next = orthoPoints[i + 2]; // Next point in orthoPoints - - // Check if current point is a corner (changes direction) - // We use a cross product check to tolerate slight non-90-degree angles during drag - const dx1 = current.x - prev.x; - const dy1 = current.y - prev.y; - const dx2 = next.x - current.x; - const dy2 = next.y - current.y; - - // Cross product magnitude. If > 0.1, the path bends here. - const isCorner = Math.abs(dx1 * dy2 - dy1 * dx2) > 0.1; - - if (isCorner) { - controlPoints.push({ - id: `cp-${Date.now()}-${i}`, - x: current.x, - y: current.y, - }); - } - } - - return controlPoints; -}