feat(sim): Phase 1d #3 + #5 — WASM pre-boot on mount + delete dead wire* utils

#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) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 22:24:31 +02:00
parent 54936ef660
commit 29d76348aa
5 changed files with 8 additions and 638 deletions

View File

@ -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', () => {

View File

@ -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);

View File

@ -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<string>();
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<string, number> {
const offsets = new Map<string, number>();
// 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,
};
}

View File

@ -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;
}

View File

@ -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;
}