@vessel-dsp/core 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +6 -0
- package/package.json +56 -0
- package/src/editor/commands.ts +344 -0
- package/src/editor/factory.ts +148 -0
- package/src/editor/history.ts +142 -0
- package/src/editor/index.ts +11 -0
- package/src/editor/layout.ts +207 -0
- package/src/formats/circuit-json/serializer.ts +1410 -0
- package/src/formats/document.ts +274 -0
- package/src/formats/interchange/parser.ts +1165 -0
- package/src/formats/interchange/serializer.ts +594 -0
- package/src/formats/ltspice/catalog.ts +181 -0
- package/src/formats/ltspice/encoding.ts +151 -0
- package/src/formats/ltspice/parser.ts +432 -0
- package/src/formats/ltspice/serializer.ts +169 -0
- package/src/formats/schx/catalog.ts +439 -0
- package/src/formats/schx/parser.ts +261 -0
- package/src/formats/schx/runtime-descriptors.ts +502 -0
- package/src/formats/schx/serializer.ts +211 -0
- package/src/formats/schx/transforms.ts +38 -0
- package/src/formats/spice/parser.ts +373 -0
- package/src/formats/spice/serializer.ts +43 -0
- package/src/index.ts +205 -0
- package/src/model/connectivity.ts +239 -0
- package/src/model/netlist.ts +375 -0
- package/src/model/properties.ts +101 -0
- package/src/model/quantity.ts +173 -0
- package/src/model/types.ts +309 -0
- package/src/model/validation.ts +985 -0
- package/src/model/wires.ts +86 -0
- package/src/panel/extract.ts +878 -0
- package/src/panel/index.ts +39 -0
- package/src/panel/knobs.ts +70 -0
- package/src/panel/protocol.ts +117 -0
- package/src/panel/types.ts +180 -0
- package/src/preview/bounds.ts +85 -0
- package/src/preview/box-layout.ts +24 -0
- package/src/preview/colors.ts +43 -0
- package/src/preview/hanging.ts +94 -0
- package/src/preview/junctions.ts +94 -0
- package/src/preview/label-layout.ts +90 -0
- package/src/preview/ports.ts +101 -0
- package/src/preview/renderable-wires.ts +113 -0
- package/src/preview/routing.ts +15 -0
- package/src/preview/snap.ts +104 -0
- package/src/preview/symbols/analog-switch.svg +17 -0
- package/src/preview/symbols/battery.svg +16 -0
- package/src/preview/symbols/bbd.svg +21 -0
- package/src/preview/symbols/bjt-npn.svg +16 -0
- package/src/preview/symbols/bjt-pnp.svg +17 -0
- package/src/preview/symbols/capacitor-electrolytic.svg +13 -0
- package/src/preview/symbols/capacitor.svg +12 -0
- package/src/preview/symbols/current-source.svg +14 -0
- package/src/preview/symbols/delay-ic.svg +22 -0
- package/src/preview/symbols/diode-schottky.svg +12 -0
- package/src/preview/symbols/diode-zener.svg +12 -0
- package/src/preview/symbols/diode.svg +13 -0
- package/src/preview/symbols/flipflop.svg +20 -0
- package/src/preview/symbols/ground.svg +12 -0
- package/src/preview/symbols/ic-block.svg +20 -0
- package/src/preview/symbols/ic.svg +19 -0
- package/src/preview/symbols/inductor.svg +11 -0
- package/src/preview/symbols/jack-input.svg +16 -0
- package/src/preview/symbols/jack-output.svg +16 -0
- package/src/preview/symbols/jfet-junction-n.svg +17 -0
- package/src/preview/symbols/jfet-n.svg +17 -0
- package/src/preview/symbols/jfet-p.svg +17 -0
- package/src/preview/symbols/label.svg +8 -0
- package/src/preview/symbols/led.svg +18 -0
- package/src/preview/symbols/mosfet-n.svg +21 -0
- package/src/preview/symbols/mosfet-p.svg +21 -0
- package/src/preview/symbols/named-wire.svg +11 -0
- package/src/preview/symbols/opamp.svg +21 -0
- package/src/preview/symbols/optocoupler.svg +30 -0
- package/src/preview/symbols/ota.svg +20 -0
- package/src/preview/symbols/pentode.svg +25 -0
- package/src/preview/symbols/photoresistor.svg +19 -0
- package/src/preview/symbols/port.svg +8 -0
- package/src/preview/symbols/potentiometer.svg +15 -0
- package/src/preview/symbols/power-amp.svg +20 -0
- package/src/preview/symbols/rail.svg +11 -0
- package/src/preview/symbols/regulator.svg +13 -0
- package/src/preview/symbols/relay.svg +20 -0
- package/src/preview/symbols/resistor.svg +11 -0
- package/src/preview/symbols/svg-content.ts +59 -0
- package/src/preview/symbols/switch-3pdt.svg +32 -0
- package/src/preview/symbols/switch-rotary.svg +23 -0
- package/src/preview/symbols/switch-spdt.svg +16 -0
- package/src/preview/symbols/switch-spst.svg +14 -0
- package/src/preview/symbols/switch-toggle.svg +14 -0
- package/src/preview/symbols/transformer.svg +17 -0
- package/src/preview/symbols/triode.svg +17 -0
- package/src/preview/symbols/tube-diode.svg +13 -0
- package/src/preview/symbols/unsupported.svg +8 -0
- package/src/preview/symbols/variable-resistor.svg +13 -0
- package/src/preview/symbols/voltage-source.svg +15 -0
- package/src/preview/symbols.ts +207 -0
- package/src/preview/wire-chains.ts +153 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Point, Wire } from '../model/types';
|
|
2
|
+
|
|
3
|
+
export function findJunctions(
|
|
4
|
+
wires: readonly Wire[],
|
|
5
|
+
terminals: readonly Point[],
|
|
6
|
+
): readonly Point[] {
|
|
7
|
+
const endpointCounts = new Map<string, number>();
|
|
8
|
+
const knownPoints = new Map<string, Point>();
|
|
9
|
+
|
|
10
|
+
for (const wire of wires) {
|
|
11
|
+
for (const endpoint of wire.endpoints) {
|
|
12
|
+
const key = pointKey(endpoint);
|
|
13
|
+
endpointCounts.set(key, (endpointCounts.get(key) ?? 0) + 1);
|
|
14
|
+
if (!knownPoints.has(key)) {
|
|
15
|
+
knownPoints.set(key, endpoint);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const junctions = new Map<string, Point>();
|
|
21
|
+
|
|
22
|
+
for (const [key, count] of endpointCounts) {
|
|
23
|
+
const point = knownPoints.get(key);
|
|
24
|
+
if (point === undefined) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (count >= 3) {
|
|
29
|
+
junctions.set(key, point);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (count >= 1 && hasMidSegmentHit(point, wires)) {
|
|
34
|
+
junctions.set(key, point);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const terminal of terminals) {
|
|
39
|
+
const key = pointKey(terminal);
|
|
40
|
+
if (junctions.has(key)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (hasMidSegmentHit(terminal, wires)) {
|
|
44
|
+
junctions.set(key, terminal);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return Array.from(junctions.values());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasMidSegmentHit(point: Point, wires: readonly Wire[]): boolean {
|
|
52
|
+
for (const wire of wires) {
|
|
53
|
+
const a = wire.endpoints[0];
|
|
54
|
+
const b = wire.endpoints[1];
|
|
55
|
+
if (pointEquals(point, a) || pointEquals(point, b)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (pointOnSegment(point, a, b)) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pointOnSegment(p: Point, a: Point, b: Point): boolean {
|
|
66
|
+
const cross = (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
|
|
67
|
+
if (cross !== 0) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const minX = Math.min(a.x, b.x);
|
|
71
|
+
const maxX = Math.max(a.x, b.x);
|
|
72
|
+
const minY = Math.min(a.y, b.y);
|
|
73
|
+
const maxY = Math.max(a.y, b.y);
|
|
74
|
+
if (minX === maxX && minY === maxY) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const inXRange = p.x > minX && p.x < maxX;
|
|
78
|
+
const inYRange = p.y > minY && p.y < maxY;
|
|
79
|
+
if (minX === maxX) {
|
|
80
|
+
return inYRange;
|
|
81
|
+
}
|
|
82
|
+
if (minY === maxY) {
|
|
83
|
+
return inXRange;
|
|
84
|
+
}
|
|
85
|
+
return inXRange && inYRange;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pointEquals(a: Point, b: Point): boolean {
|
|
89
|
+
return a.x === b.x && a.y === b.y;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pointKey(p: Point): string {
|
|
93
|
+
return `${p.x},${p.y}`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export type LabelTextBoxLayout = Readonly<{
|
|
2
|
+
lines: readonly string[];
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
paddingX: number;
|
|
6
|
+
paddingY: number;
|
|
7
|
+
fontSize: number;
|
|
8
|
+
lineHeight: number;
|
|
9
|
+
}>;
|
|
10
|
+
|
|
11
|
+
const LABEL_TEXTBOX_MAX_CHARS = 52;
|
|
12
|
+
const LABEL_TEXTBOX_MIN_WIDTH = 120;
|
|
13
|
+
const LABEL_TEXTBOX_MAX_WIDTH = 320;
|
|
14
|
+
const LABEL_TEXTBOX_CHAR_WIDTH = 5.8;
|
|
15
|
+
const LABEL_TEXTBOX_PADDING_X = 8;
|
|
16
|
+
const LABEL_TEXTBOX_PADDING_Y = 6;
|
|
17
|
+
const LABEL_TEXTBOX_FONT_SIZE = 10;
|
|
18
|
+
const LABEL_TEXTBOX_LINE_HEIGHT = 14;
|
|
19
|
+
|
|
20
|
+
export function shouldRenderLabelTextBox(text: string, subtext: string | null): boolean {
|
|
21
|
+
return isLongOrMultiline(text) || (subtext !== null && isLongOrMultiline(subtext));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function computeLabelTextBoxLayout(text: string, subtext: string | null): LabelTextBoxLayout {
|
|
25
|
+
const lines = wrapLabelText(labelTextBody(text, subtext));
|
|
26
|
+
const longestLine = Math.max(...lines.map((line) => line.length), 1);
|
|
27
|
+
const width = clamp(
|
|
28
|
+
Math.ceil(longestLine * LABEL_TEXTBOX_CHAR_WIDTH + LABEL_TEXTBOX_PADDING_X * 2),
|
|
29
|
+
LABEL_TEXTBOX_MIN_WIDTH,
|
|
30
|
+
LABEL_TEXTBOX_MAX_WIDTH,
|
|
31
|
+
);
|
|
32
|
+
const height = LABEL_TEXTBOX_PADDING_Y * 2 + lines.length * LABEL_TEXTBOX_LINE_HEIGHT;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
lines,
|
|
36
|
+
width,
|
|
37
|
+
height,
|
|
38
|
+
paddingX: LABEL_TEXTBOX_PADDING_X,
|
|
39
|
+
paddingY: LABEL_TEXTBOX_PADDING_Y,
|
|
40
|
+
fontSize: LABEL_TEXTBOX_FONT_SIZE,
|
|
41
|
+
lineHeight: LABEL_TEXTBOX_LINE_HEIGHT,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isLongOrMultiline(value: string): boolean {
|
|
46
|
+
return value.includes('\n') || value.length > LABEL_TEXTBOX_MAX_CHARS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function labelTextBody(text: string, subtext: string | null): string {
|
|
50
|
+
if (subtext === null) {
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
return `${text}\n${subtext}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function wrapLabelText(text: string): readonly string[] {
|
|
57
|
+
const lines = text.split(/\r?\n/).flatMap((line) => wrapLabelLine(line));
|
|
58
|
+
return lines.length === 0 ? [''] : lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function wrapLabelLine(line: string): readonly string[] {
|
|
62
|
+
if (line.length <= LABEL_TEXTBOX_MAX_CHARS) {
|
|
63
|
+
return [line];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const wrapped: string[] = [];
|
|
67
|
+
let rest = line.trimEnd();
|
|
68
|
+
while (rest.length > LABEL_TEXTBOX_MAX_CHARS) {
|
|
69
|
+
const breakAt = findWrapIndex(rest);
|
|
70
|
+
wrapped.push(rest.slice(0, breakAt).trimEnd());
|
|
71
|
+
rest = rest.slice(breakAt).trimStart();
|
|
72
|
+
}
|
|
73
|
+
wrapped.push(rest);
|
|
74
|
+
return wrapped;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findWrapIndex(text: string): number {
|
|
78
|
+
const search = text.slice(0, LABEL_TEXTBOX_MAX_CHARS + 1);
|
|
79
|
+
const spaceIndex = search.lastIndexOf(' ');
|
|
80
|
+
if (spaceIndex > 0) {
|
|
81
|
+
return spaceIndex;
|
|
82
|
+
}
|
|
83
|
+
return LABEL_TEXTBOX_MAX_CHARS;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clamp(value: number, min: number, max: number): number {
|
|
87
|
+
if (value < min) return min;
|
|
88
|
+
if (value > max) return max;
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Component, Point, Wire } from '../model/types';
|
|
2
|
+
|
|
3
|
+
export type WireBodyHit = Readonly<{
|
|
4
|
+
wireId: string;
|
|
5
|
+
position: Point;
|
|
6
|
+
}>;
|
|
7
|
+
|
|
8
|
+
export type Port = Readonly<{
|
|
9
|
+
componentId: string;
|
|
10
|
+
terminalName: string;
|
|
11
|
+
position: Point;
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
// Flatten every component's terminals into a port list. The position is the
|
|
15
|
+
// raw catalog terminal position — the same coordinate space used in wire
|
|
16
|
+
// endpoints, so an add-wire command built from these positions lands on
|
|
17
|
+
// existing junction / connectivity logic without further mapping.
|
|
18
|
+
export function collectPorts(components: readonly Component[]): readonly Port[] {
|
|
19
|
+
const ports: Port[] = [];
|
|
20
|
+
for (const component of components) {
|
|
21
|
+
for (const terminal of component.terminals) {
|
|
22
|
+
ports.push({
|
|
23
|
+
componentId: component.id,
|
|
24
|
+
terminalName: terminal.name,
|
|
25
|
+
position: terminal.position,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return ports;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Find the port nearest to `cursor` within `radius` distance, optionally
|
|
33
|
+
// excluding a particular port (used to avoid snapping back onto the wire's
|
|
34
|
+
// own start terminal during a wire-create drag).
|
|
35
|
+
export function findNearestPort<T extends Port>(
|
|
36
|
+
ports: readonly T[],
|
|
37
|
+
cursor: Point,
|
|
38
|
+
radius: number,
|
|
39
|
+
exclude: { componentId: string; terminalName: string } | null = null,
|
|
40
|
+
): T | null {
|
|
41
|
+
let best: T | null = null;
|
|
42
|
+
let bestDistance = radius;
|
|
43
|
+
for (const port of ports) {
|
|
44
|
+
if (
|
|
45
|
+
exclude !== null &&
|
|
46
|
+
port.componentId === exclude.componentId &&
|
|
47
|
+
port.terminalName === exclude.terminalName
|
|
48
|
+
) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const dx = port.position.x - cursor.x;
|
|
52
|
+
const dy = port.position.y - cursor.y;
|
|
53
|
+
const distance = Math.hypot(dx, dy);
|
|
54
|
+
if (distance < bestDistance || (best === null && distance <= bestDistance)) {
|
|
55
|
+
best = port;
|
|
56
|
+
bestDistance = distance;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Find the wire whose body is closest to `cursor`, within `radius`, and return
|
|
63
|
+
// the projection of the cursor onto that wire. Used to snap a wire-create
|
|
64
|
+
// gesture to a wire body so dropping there auto-forms a T-junction.
|
|
65
|
+
export function findNearestWireBodyHit(
|
|
66
|
+
wires: readonly Wire[],
|
|
67
|
+
cursor: Point,
|
|
68
|
+
radius: number,
|
|
69
|
+
excludeWireId: string | null = null,
|
|
70
|
+
): WireBodyHit | null {
|
|
71
|
+
let best: WireBodyHit | null = null;
|
|
72
|
+
let bestDistance = radius;
|
|
73
|
+
for (const wire of wires) {
|
|
74
|
+
if (excludeWireId !== null && wire.id === excludeWireId) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const projection = projectOntoSegment(cursor, wire.endpoints[0], wire.endpoints[1]);
|
|
78
|
+
const dx = projection.x - cursor.x;
|
|
79
|
+
const dy = projection.y - cursor.y;
|
|
80
|
+
const distance = Math.hypot(dx, dy);
|
|
81
|
+
if (distance < bestDistance) {
|
|
82
|
+
best = { wireId: wire.id, position: projection };
|
|
83
|
+
bestDistance = distance;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return best;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function projectOntoSegment(p: Point, a: Point, b: Point): Point {
|
|
90
|
+
const dx = b.x - a.x;
|
|
91
|
+
const dy = b.y - a.y;
|
|
92
|
+
if (dx === 0 && dy === 0) {
|
|
93
|
+
return { x: a.x, y: a.y };
|
|
94
|
+
}
|
|
95
|
+
const t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy);
|
|
96
|
+
const clamped = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
97
|
+
return {
|
|
98
|
+
x: Math.round(a.x + clamped * dx),
|
|
99
|
+
y: Math.round(a.y + clamped * dy),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { CircuitDocument, Point, Wire } from '../model/types';
|
|
2
|
+
|
|
3
|
+
export function buildRenderableWires(document: CircuitDocument): readonly Wire[] {
|
|
4
|
+
const splitCandidates = collectSplitCandidates(document);
|
|
5
|
+
const result: Wire[] = [];
|
|
6
|
+
|
|
7
|
+
for (const wire of document.wires) {
|
|
8
|
+
const segments = splitWire(wire, splitCandidates);
|
|
9
|
+
result.push(...segments);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function collectSplitCandidates(document: CircuitDocument): readonly Point[] {
|
|
16
|
+
const byKey = new Map<string, Point>();
|
|
17
|
+
|
|
18
|
+
for (const wire of document.wires) {
|
|
19
|
+
addPoint(byKey, wire.endpoints[0]);
|
|
20
|
+
addPoint(byKey, wire.endpoints[1]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const component of document.components) {
|
|
24
|
+
for (const terminal of component.terminals) {
|
|
25
|
+
addPoint(byKey, terminal.position);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return Array.from(byKey.values());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function splitWire(wire: Wire, candidates: readonly Point[]): readonly Wire[] {
|
|
33
|
+
const a = wire.endpoints[0];
|
|
34
|
+
const b = wire.endpoints[1];
|
|
35
|
+
const points = [a, b];
|
|
36
|
+
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
if (pointEquals(candidate, a) || pointEquals(candidate, b)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (pointOnSegment(candidate, a, b)) {
|
|
42
|
+
points.push(candidate);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (points.length === 2) {
|
|
47
|
+
return [wire];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ordered = points.slice().sort((p, q) => segmentParameter(p, a, b) - segmentParameter(q, a, b));
|
|
51
|
+
const segments: Wire[] = [];
|
|
52
|
+
for (let i = 0; i < ordered.length - 1; i += 1) {
|
|
53
|
+
const start = ordered[i];
|
|
54
|
+
const end = ordered[i + 1];
|
|
55
|
+
if (start === undefined || end === undefined || pointEquals(start, end)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
segments.push({
|
|
59
|
+
id: `${wire.id}-${segments.length + 1}`,
|
|
60
|
+
endpoints: [start, end],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return segments;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function addPoint(points: Map<string, Point>, point: Point): void {
|
|
68
|
+
const key = pointKey(point);
|
|
69
|
+
if (!points.has(key)) {
|
|
70
|
+
points.set(key, point);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function segmentParameter(point: Point, a: Point, b: Point): number {
|
|
75
|
+
const dx = b.x - a.x;
|
|
76
|
+
const dy = b.y - a.y;
|
|
77
|
+
const lengthSq = dx * dx + dy * dy;
|
|
78
|
+
if (lengthSq === 0) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
return ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSq;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pointOnSegment(point: Point, a: Point, b: Point): boolean {
|
|
85
|
+
const cross = (point.x - a.x) * (b.y - a.y) - (point.y - a.y) * (b.x - a.x);
|
|
86
|
+
if (cross !== 0) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const minX = Math.min(a.x, b.x);
|
|
90
|
+
const maxX = Math.max(a.x, b.x);
|
|
91
|
+
const minY = Math.min(a.y, b.y);
|
|
92
|
+
const maxY = Math.max(a.y, b.y);
|
|
93
|
+
if (minX === maxX && minY === maxY) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const inXRange = point.x > minX && point.x < maxX;
|
|
97
|
+
const inYRange = point.y > minY && point.y < maxY;
|
|
98
|
+
if (minX === maxX) {
|
|
99
|
+
return inYRange;
|
|
100
|
+
}
|
|
101
|
+
if (minY === maxY) {
|
|
102
|
+
return inXRange;
|
|
103
|
+
}
|
|
104
|
+
return inXRange && inYRange;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pointEquals(a: Point, b: Point): boolean {
|
|
108
|
+
return a.x === b.x && a.y === b.y;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pointKey(point: Point): string {
|
|
112
|
+
return `${point.x},${point.y}`;
|
|
113
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Point } from '../model/types';
|
|
2
|
+
|
|
3
|
+
export function orthogonalPath(a: Point, b: Point): readonly Point[] {
|
|
4
|
+
if (a.x === b.x || a.y === b.y) {
|
|
5
|
+
return [a, b];
|
|
6
|
+
}
|
|
7
|
+
const dx = Math.abs(b.x - a.x);
|
|
8
|
+
const dy = Math.abs(b.y - a.y);
|
|
9
|
+
const corner: Point = dx >= dy ? { x: b.x, y: a.y } : { x: a.x, y: b.y };
|
|
10
|
+
return [a, corner, b];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function pointsToSvg(points: readonly Point[]): string {
|
|
14
|
+
return points.map((p) => `${p.x},${p.y}`).join(' ');
|
|
15
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Component, Point, Wire } from '../model/types';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_SNAP_RADIUS = 12;
|
|
4
|
+
|
|
5
|
+
export type SnapResult = Readonly<{
|
|
6
|
+
origin: Point;
|
|
7
|
+
snappedTo: Point | null;
|
|
8
|
+
distance: number;
|
|
9
|
+
}>;
|
|
10
|
+
|
|
11
|
+
export function findSnap(
|
|
12
|
+
draggedComponent: Component,
|
|
13
|
+
candidateOrigin: Point,
|
|
14
|
+
others: readonly Component[],
|
|
15
|
+
radius: number = DEFAULT_SNAP_RADIUS,
|
|
16
|
+
wires: readonly Wire[] = [],
|
|
17
|
+
): SnapResult {
|
|
18
|
+
const dx = candidateOrigin.x - draggedComponent.origin.x;
|
|
19
|
+
const dy = candidateOrigin.y - draggedComponent.origin.y;
|
|
20
|
+
const draggedTerminalKeys = new Set(draggedComponent.terminals.map((t) => pointKey(t.position)));
|
|
21
|
+
|
|
22
|
+
let bestDelta: Point | null = null;
|
|
23
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
24
|
+
let bestTarget: Point | null = null;
|
|
25
|
+
|
|
26
|
+
for (const draggedTerminal of draggedComponent.terminals) {
|
|
27
|
+
const projected: Point = {
|
|
28
|
+
x: draggedTerminal.position.x + dx,
|
|
29
|
+
y: draggedTerminal.position.y + dy,
|
|
30
|
+
};
|
|
31
|
+
for (const other of others) {
|
|
32
|
+
if (other.id === draggedComponent.id) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (const terminal of other.terminals) {
|
|
36
|
+
const ddx = terminal.position.x - projected.x;
|
|
37
|
+
const ddy = terminal.position.y - projected.y;
|
|
38
|
+
const distance = Math.hypot(ddx, ddy);
|
|
39
|
+
if (distance <= radius && distance < bestDistance) {
|
|
40
|
+
bestDistance = distance;
|
|
41
|
+
bestDelta = { x: ddx, y: ddy };
|
|
42
|
+
bestTarget = terminal.position;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const draggedTerminal of draggedComponent.terminals) {
|
|
49
|
+
const projected: Point = {
|
|
50
|
+
x: draggedTerminal.position.x + dx,
|
|
51
|
+
y: draggedTerminal.position.y + dy,
|
|
52
|
+
};
|
|
53
|
+
for (const wire of wires) {
|
|
54
|
+
if (wireTouchesDraggedTerminal(wire, draggedTerminalKeys)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const target = nearestInteriorPointOnSegment(projected, wire.endpoints[0], wire.endpoints[1]);
|
|
58
|
+
if (target === null) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const distance = Math.hypot(target.x - projected.x, target.y - projected.y);
|
|
62
|
+
if (distance <= radius && distance < bestDistance) {
|
|
63
|
+
bestDistance = distance;
|
|
64
|
+
bestDelta = { x: target.x - projected.x, y: target.y - projected.y };
|
|
65
|
+
bestTarget = target;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (bestDelta === null || bestTarget === null) {
|
|
71
|
+
return { origin: candidateOrigin, snappedTo: null, distance: Number.POSITIVE_INFINITY };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
origin: { x: candidateOrigin.x + bestDelta.x, y: candidateOrigin.y + bestDelta.y },
|
|
76
|
+
snappedTo: bestTarget,
|
|
77
|
+
distance: bestDistance,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function wireTouchesDraggedTerminal(wire: Wire, terminalKeys: ReadonlySet<string>): boolean {
|
|
82
|
+
return terminalKeys.has(pointKey(wire.endpoints[0])) || terminalKeys.has(pointKey(wire.endpoints[1]));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nearestInteriorPointOnSegment(point: Point, a: Point, b: Point): Point | null {
|
|
86
|
+
const dx = b.x - a.x;
|
|
87
|
+
const dy = b.y - a.y;
|
|
88
|
+
const lengthSq = dx * dx + dy * dy;
|
|
89
|
+
if (lengthSq === 0) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSq;
|
|
93
|
+
if (t <= 0 || t >= 1) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
x: a.x + dx * t,
|
|
98
|
+
y: a.y + dy * t,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function pointKey(point: Point): string {
|
|
103
|
+
return `${point.x},${point.y}`;
|
|
104
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25 -25 50 50" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!--
|
|
3
|
+
kind: analog-switch (one element of a CD4066-style bilateral switch)
|
|
4
|
+
terminals: a (SVG -20,-10), b (SVG 20,-10), ctrl (SVG 0,20)
|
|
5
|
+
Two contacts with a control input on the bottom — like an SPST but with electronic control.
|
|
6
|
+
-->
|
|
7
|
+
<g stroke-width="0.95">
|
|
8
|
+
<line x1="-20" y1="-10" x2="-7" y2="-10"/>
|
|
9
|
+
<line x1="7" y1="-10" x2="20" y2="-10"/>
|
|
10
|
+
<line x1="0" y1="6" x2="0" y2="20"/>
|
|
11
|
+
</g>
|
|
12
|
+
<circle cx="-5" cy="-10" r="1.5"/>
|
|
13
|
+
<circle cx="5" cy="-10" r="1.5"/>
|
|
14
|
+
<line x1="-5" y1="-10" x2="5" y2="-10" stroke-dasharray="2 2"/>
|
|
15
|
+
<rect x="-6" y="0" width="12" height="6" rx="1"/>
|
|
16
|
+
<text x="0" y="-13" font-size="3.5" font-family="ui-sans-serif, system-ui, sans-serif" fill="currentColor" stroke="none" text-anchor="middle">SW</text>
|
|
17
|
+
</svg>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25 -25 50 50" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!--
|
|
3
|
+
kind: battery / DC supply
|
|
4
|
+
terminals: + (SVG 0,-20), - (SVG 0,20)
|
|
5
|
+
Long plate = +, short plate = -. Top lead is the + terminal.
|
|
6
|
+
-->
|
|
7
|
+
<g stroke-width="0.95">
|
|
8
|
+
<line x1="0" y1="-20" x2="0" y2="-5"/>
|
|
9
|
+
<line x1="0" y1="5" x2="0" y2="20"/>
|
|
10
|
+
</g>
|
|
11
|
+
<line x1="-7" y1="-5" x2="7" y2="-5"/>
|
|
12
|
+
<line x1="-3" y1="-2" x2="3" y2="-2"/>
|
|
13
|
+
<line x1="-7" y1="2" x2="7" y2="2"/>
|
|
14
|
+
<line x1="-3" y1="5" x2="3" y2="5"/>
|
|
15
|
+
<text x="-12" y="-4" font-size="6" font-family="ui-sans-serif, system-ui, sans-serif" fill="currentColor" stroke="none">+</text>
|
|
16
|
+
</svg>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-35 -25 70 50" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!--
|
|
3
|
+
kind: bbd (bucket-brigade delay — MN3007 / MN3008 / MN3205 family)
|
|
4
|
+
terminals: vdd (-30,-20), vgg (-30,0), vss (-30,20), cp1 (0,-20), cp2 (0,20), in (-30,10), out1 (30,-10), out2 (30,10)
|
|
5
|
+
DIP-8 style rectangle, "BBD" label and small bucket icon on the face.
|
|
6
|
+
-->
|
|
7
|
+
<g stroke-width="0.95">
|
|
8
|
+
<line x1="-30" y1="-20" x2="-20" y2="-20"/>
|
|
9
|
+
<line x1="-30" y1="0" x2="-20" y2="0"/>
|
|
10
|
+
<line x1="-30" y1="20" x2="-20" y2="20"/>
|
|
11
|
+
<line x1="-30" y1="10" x2="-20" y2="10"/>
|
|
12
|
+
<line x1="0" y1="-20" x2="0" y2="-15"/>
|
|
13
|
+
<line x1="0" y1="15" x2="0" y2="20"/>
|
|
14
|
+
<line x1="20" y1="-10" x2="30" y2="-10"/>
|
|
15
|
+
<line x1="20" y1="10" x2="30" y2="10"/>
|
|
16
|
+
</g>
|
|
17
|
+
<rect x="-20" y="-15" width="40" height="30" rx="2"/>
|
|
18
|
+
<circle cx="-17" cy="-12" r="1.2" fill="currentColor" stroke="none"/>
|
|
19
|
+
<text x="0" y="-3" font-size="6" font-family="ui-sans-serif, system-ui, sans-serif" fill="currentColor" stroke="none" text-anchor="middle">BBD</text>
|
|
20
|
+
<text x="0" y="8" font-size="3.5" font-family="ui-sans-serif, system-ui, sans-serif" fill="currentColor" stroke="none" text-anchor="middle">MN3007</text>
|
|
21
|
+
</svg>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25 -25 50 50" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!--
|
|
3
|
+
kind: bjt (NPN variant)
|
|
4
|
+
terminals: collector (SVG 0,-20), base (SVG -20,0), emitter (SVG 10,20)
|
|
5
|
+
-->
|
|
6
|
+
<g stroke-width="0.95">
|
|
7
|
+
<line x1="-20" y1="0" x2="-6" y2="0"/>
|
|
8
|
+
<line x1="4" y1="-7" x2="0" y2="-20"/>
|
|
9
|
+
<line x1="4" y1="7" x2="10" y2="20"/>
|
|
10
|
+
</g>
|
|
11
|
+
<circle cx="0" cy="0" r="11"/>
|
|
12
|
+
<line x1="-6" y1="-5" x2="-6" y2="5"/>
|
|
13
|
+
<line x1="-6" y1="-3" x2="4" y2="-7"/>
|
|
14
|
+
<line x1="-6" y1="3" x2="4" y2="7"/>
|
|
15
|
+
<path d="M 4 7 L -0.3 6.9 L 1.2 4.1 Z" fill="currentColor" stroke="none"/>
|
|
16
|
+
</svg>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25 -25 50 50" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!--
|
|
3
|
+
kind: bjt (PNP variant)
|
|
4
|
+
terminals: collector (SVG 0,-20), base (SVG -20,0), emitter (SVG 10,20)
|
|
5
|
+
Arrow points INWARD (emitter -> base) to distinguish PNP from NPN.
|
|
6
|
+
-->
|
|
7
|
+
<g stroke-width="0.95">
|
|
8
|
+
<line x1="-20" y1="0" x2="-6" y2="0"/>
|
|
9
|
+
<line x1="4" y1="-7" x2="0" y2="-20"/>
|
|
10
|
+
<line x1="4" y1="7" x2="10" y2="20"/>
|
|
11
|
+
</g>
|
|
12
|
+
<circle cx="0" cy="0" r="11"/>
|
|
13
|
+
<line x1="-6" y1="-5" x2="-6" y2="5"/>
|
|
14
|
+
<line x1="-6" y1="-3" x2="4" y2="-7"/>
|
|
15
|
+
<line x1="-6" y1="3" x2="4" y2="7"/>
|
|
16
|
+
<path d="M -6 3 L -1.7 3.1 L -3.2 5.9 Z" fill="currentColor" stroke="none"/>
|
|
17
|
+
</svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25 -25 50 50" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!--
|
|
3
|
+
kind: capacitor (electrolytic variant)
|
|
4
|
+
terminals: a/+ (SVG 0,-20), b/- (SVG 0,20)
|
|
5
|
+
-->
|
|
6
|
+
<g stroke-width="0.95">
|
|
7
|
+
<line x1="0" y1="-20" x2="0" y2="-3"/>
|
|
8
|
+
<line x1="0" y1="5" x2="0" y2="20"/>
|
|
9
|
+
</g>
|
|
10
|
+
<line x1="-8" y1="-3" x2="8" y2="-3"/>
|
|
11
|
+
<path d="M -8 3 Q 0 7 8 3"/>
|
|
12
|
+
<text x="-9" y="-6" font-size="7" font-family="ui-sans-serif, system-ui, sans-serif" fill="currentColor" stroke="none">+</text>
|
|
13
|
+
</svg>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25 -25 50 50" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!--
|
|
3
|
+
kind: capacitor
|
|
4
|
+
terminals: a (SVG 0,-20), b (SVG 0,20)
|
|
5
|
+
-->
|
|
6
|
+
<g stroke-width="0.95">
|
|
7
|
+
<line x1="0" y1="-20" x2="0" y2="-3"/>
|
|
8
|
+
<line x1="0" y1="3" x2="0" y2="20"/>
|
|
9
|
+
</g>
|
|
10
|
+
<line x1="-8" y1="-3" x2="8" y2="-3"/>
|
|
11
|
+
<line x1="-8" y1="3" x2="8" y2="3"/>
|
|
12
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25 -25 50 50" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!--
|
|
3
|
+
kind: current-source
|
|
4
|
+
terminals: + (SVG 0,-20), - (SVG 0,20)
|
|
5
|
+
Arrow points toward + terminal (top) — direction of conventional current flow.
|
|
6
|
+
-->
|
|
7
|
+
<g stroke-width="0.95">
|
|
8
|
+
<line x1="0" y1="-20" x2="0" y2="-9"/>
|
|
9
|
+
<line x1="0" y1="9" x2="0" y2="20"/>
|
|
10
|
+
</g>
|
|
11
|
+
<circle cx="0" cy="0" r="9"/>
|
|
12
|
+
<line x1="0" y1="5" x2="0" y2="-2"/>
|
|
13
|
+
<path d="M 0 -5 L -3 0 L 3 0 Z" fill="currentColor" stroke="none"/>
|
|
14
|
+
</svg>
|