Compare commits
No commits in common. "e69f2db44c356f5f2f1eb3e23295b30beea0ff41" and "5a4e116475db10f466f1340e3db0b70e8a6b3c59" have entirely different histories.
e69f2db44c
...
5a4e116475
@ -184,7 +184,7 @@ export function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CanvasArea store={store} />
|
<CanvasArea />
|
||||||
<RightInspector model={model} selection={selection} compileResult={compileResult} />
|
<RightInspector model={model} selection={selection} compileResult={compileResult} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState, useSyncExternalStore } from "react";
|
import { useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { createSchemetaStore } from "../state/store.js";
|
import { createSchemetaStore } from "../state/store.js";
|
||||||
import type { SchemetaInstance, SchemetaModel, SchemetaStore } from "../state/store.js";
|
import type { SchemetaInstance, SchemetaModel, SchemetaStore } from "../state/store.js";
|
||||||
|
|
||||||
@ -11,8 +11,6 @@ const AUTO_X_START = 60;
|
|||||||
const AUTO_Y_START = 60;
|
const AUTO_Y_START = 60;
|
||||||
const AUTO_X_GAP = 210;
|
const AUTO_X_GAP = 210;
|
||||||
const AUTO_Y_GAP = 150;
|
const AUTO_Y_GAP = 150;
|
||||||
const NUDGE_STEP = 10;
|
|
||||||
const NUDGE_STEP_FAST = 20;
|
|
||||||
|
|
||||||
const BOOTSTRAP_MODEL: SchemetaModel = {
|
const BOOTSTRAP_MODEL: SchemetaModel = {
|
||||||
symbols: {
|
symbols: {
|
||||||
@ -69,35 +67,13 @@ type NodePlacement = {
|
|||||||
rightPins: SymbolPin[];
|
rightPins: SymbolPin[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CanvasPoint = { x: number; y: number };
|
|
||||||
|
|
||||||
type CanvasWireSegment = {
|
|
||||||
a: CanvasPoint;
|
|
||||||
b: CanvasPoint;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CanvasWire = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
netClass: string | null;
|
|
||||||
segments: CanvasWireSegment[];
|
|
||||||
labelPoint: CanvasPoint | null;
|
|
||||||
highlight: boolean;
|
|
||||||
source: "routed" | "fallback";
|
|
||||||
};
|
|
||||||
|
|
||||||
type DragState = {
|
type DragState = {
|
||||||
refs: string[];
|
ref: string;
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
startClientX: number;
|
startClientX: number;
|
||||||
startClientY: number;
|
startClientY: number;
|
||||||
starts: Map<string, { x: number; y: number }>;
|
startX: number;
|
||||||
};
|
startY: number;
|
||||||
|
|
||||||
type BoxSelectState = {
|
|
||||||
pointerId: number;
|
|
||||||
start: CanvasPoint;
|
|
||||||
current: CanvasPoint;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fallbackStore = createSchemetaStore({
|
const fallbackStore = createSchemetaStore({
|
||||||
@ -123,157 +99,6 @@ function toDimension(value: unknown, fallback: number, min = 80, max = 320) {
|
|||||||
return Math.max(min, Math.min(max, Math.round(parsed)));
|
return Math.max(min, Math.min(max, Math.round(parsed)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
||||||
if (typeof value !== "object" || value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asPoint(value: unknown): CanvasPoint | null {
|
|
||||||
if (Array.isArray(value) && value.length >= 2) {
|
|
||||||
const x = Number(value[0]);
|
|
||||||
const y = Number(value[1]);
|
|
||||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = asRecord(value);
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const x = Number(record.x);
|
|
||||||
const y = Number(record.y);
|
|
||||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotatePoint(point: CanvasPoint, center: CanvasPoint, rotationDeg: number): CanvasPoint {
|
|
||||||
if (!Number.isFinite(rotationDeg) || rotationDeg === 0) {
|
|
||||||
return point;
|
|
||||||
}
|
|
||||||
|
|
||||||
const radians = (rotationDeg * Math.PI) / 180;
|
|
||||||
const cos = Math.cos(radians);
|
|
||||||
const sin = Math.sin(radians);
|
|
||||||
const dx = point.x - center.x;
|
|
||||||
const dy = point.y - center.y;
|
|
||||||
return {
|
|
||||||
x: center.x + dx * cos - dy * sin,
|
|
||||||
y: center.y + dx * sin + dy * cos
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointsToSegments(points: CanvasPoint[]): CanvasWireSegment[] {
|
|
||||||
const segments: CanvasWireSegment[] = [];
|
|
||||||
for (let index = 1; index < points.length; index += 1) {
|
|
||||||
const a = points[index - 1];
|
|
||||||
const b = points[index];
|
|
||||||
if (a.x === b.x && a.y === b.y) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
segments.push({ a, b });
|
|
||||||
}
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asSegment(value: unknown): CanvasWireSegment | null {
|
|
||||||
const record = asRecord(value);
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = asPoint(record.a ?? record.start ?? record.from);
|
|
||||||
const b = asPoint(record.b ?? record.end ?? record.to);
|
|
||||||
if (a && b) {
|
|
||||||
return { a, b };
|
|
||||||
}
|
|
||||||
|
|
||||||
const x1 = Number(record.x1);
|
|
||||||
const y1 = Number(record.y1);
|
|
||||||
const x2 = Number(record.x2);
|
|
||||||
const y2 = Number(record.y2);
|
|
||||||
if (Number.isFinite(x1) && Number.isFinite(y1) && Number.isFinite(x2) && Number.isFinite(y2)) {
|
|
||||||
return { a: { x: x1, y: y1 }, b: { x: x2, y: y2 } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSegmentsFromUnknown(value: unknown): CanvasWireSegment[] {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const points = value.map((entry) => asPoint(entry)).filter((entry): entry is CanvasPoint => entry !== null);
|
|
||||||
if (points.length >= 2 && points.length === value.length) {
|
|
||||||
return pointsToSegments(points);
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments: CanvasWireSegment[] = [];
|
|
||||||
value.forEach((entry) => {
|
|
||||||
const segment = asSegment(entry);
|
|
||||||
if (segment) {
|
|
||||||
segments.push(segment);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nested = asRecord(entry);
|
|
||||||
if (!nested) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nestedSegments = [
|
|
||||||
...getSegmentsFromUnknown(nested.segments),
|
|
||||||
...getSegmentsFromUnknown(nested.path),
|
|
||||||
...getSegmentsFromUnknown(nested.polyline),
|
|
||||||
...getSegmentsFromUnknown(nested.points)
|
|
||||||
];
|
|
||||||
segments.push(...nestedSegments);
|
|
||||||
});
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
function segmentLengthSquared(segment: CanvasWireSegment): number {
|
|
||||||
const dx = segment.b.x - segment.a.x;
|
|
||||||
const dy = segment.b.y - segment.a.y;
|
|
||||||
return dx * dx + dy * dy;
|
|
||||||
}
|
|
||||||
|
|
||||||
function segmentMidpoint(segment: CanvasWireSegment): CanvasPoint {
|
|
||||||
return {
|
|
||||||
x: (segment.a.x + segment.b.x) / 2,
|
|
||||||
y: (segment.a.y + segment.b.y) / 2
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickLabelPoint(segments: CanvasWireSegment[], preferred: CanvasPoint | null): CanvasPoint | null {
|
|
||||||
if (preferred) {
|
|
||||||
return preferred;
|
|
||||||
}
|
|
||||||
if (segments.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let longest = segments[0];
|
|
||||||
let bestLength = segmentLengthSquared(segments[0]);
|
|
||||||
for (let index = 1; index < segments.length; index += 1) {
|
|
||||||
const candidateLength = segmentLengthSquared(segments[index]);
|
|
||||||
if (candidateLength > bestLength) {
|
|
||||||
bestLength = candidateLength;
|
|
||||||
longest = segments[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return segmentMidpoint(longest);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSymbolNode(symbols: SchemetaModel["symbols"], instance: SchemetaInstance) {
|
function getSymbolNode(symbols: SchemetaModel["symbols"], instance: SchemetaInstance) {
|
||||||
if (!instance.symbol || !symbols || typeof symbols !== "object") {
|
if (!instance.symbol || !symbols || typeof symbols !== "object") {
|
||||||
return null;
|
return null;
|
||||||
@ -301,177 +126,6 @@ function getNodePlacement(instance: SchemetaInstance, index: number, symbols: Sc
|
|||||||
return { x, y, width, height, rotation, locked, leftPins, rightPins };
|
return { x, y, width, height, rotation, locked, leftPins, rightPins };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNetIdentity(netRecord: Record<string, unknown>, index: number) {
|
|
||||||
const named = typeof netRecord.name === "string" && netRecord.name.trim().length > 0 ? netRecord.name.trim() : null;
|
|
||||||
return {
|
|
||||||
id: named ?? `net-${index + 1}`,
|
|
||||||
label: named ?? `Net ${index + 1}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPinAnchor(
|
|
||||||
placement: NodePlacement,
|
|
||||||
instance: SchemetaInstance,
|
|
||||||
symbols: SchemetaModel["symbols"],
|
|
||||||
pinName: string | null
|
|
||||||
): CanvasPoint {
|
|
||||||
const symbol = getSymbolNode(symbols, instance) as { pins?: SymbolPin[] } | null;
|
|
||||||
const pins = Array.isArray(symbol?.pins) ? symbol.pins : [];
|
|
||||||
const normalizedPin = pinName?.trim() ?? "";
|
|
||||||
const pin = pins.find((candidate) => {
|
|
||||||
const candidateName = typeof candidate?.name === "string" ? candidate.name : "";
|
|
||||||
const candidateNumber = typeof candidate?.number === "string" ? candidate.number : "";
|
|
||||||
return normalizedPin.length > 0 && (candidateName === normalizedPin || candidateNumber === normalizedPin);
|
|
||||||
});
|
|
||||||
|
|
||||||
const offset = Math.max(0, Math.min(placement.height, toFinite(pin?.offset, placement.height * 0.5)));
|
|
||||||
let anchor: CanvasPoint;
|
|
||||||
switch (pin?.side) {
|
|
||||||
case "left":
|
|
||||||
anchor = { x: placement.x, y: placement.y + offset };
|
|
||||||
break;
|
|
||||||
case "top":
|
|
||||||
anchor = { x: placement.x + placement.width * 0.5, y: placement.y };
|
|
||||||
break;
|
|
||||||
case "bottom":
|
|
||||||
anchor = { x: placement.x + placement.width * 0.5, y: placement.y + placement.height };
|
|
||||||
break;
|
|
||||||
case "right":
|
|
||||||
default:
|
|
||||||
anchor = { x: placement.x + placement.width, y: placement.y + offset };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const center = { x: placement.x + placement.width / 2, y: placement.y + placement.height / 2 };
|
|
||||||
return rotatePoint(anchor, center, placement.rotation);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractRoutedWires(compileResult: unknown, selectedNet: string | null): CanvasWire[] {
|
|
||||||
const compile = asRecord(compileResult);
|
|
||||||
if (!compile) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const layout = asRecord(compile.layout);
|
|
||||||
const topology = asRecord(compile.topology);
|
|
||||||
const candidates: unknown[] = [layout?.routed, layout?.routes, compile.routed, compile.routes, topology?.routed];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (!Array.isArray(candidate) || candidate.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed: CanvasWire[] = [];
|
|
||||||
candidate.forEach((entry, index) => {
|
|
||||||
const route = asRecord(entry);
|
|
||||||
if (!route) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const netRecord = asRecord(route.net);
|
|
||||||
const netNameRaw =
|
|
||||||
(typeof netRecord?.name === "string" && netRecord.name.trim().length > 0 ? netRecord.name.trim() : null) ??
|
|
||||||
(typeof route.net_name === "string" && route.net_name.trim().length > 0 ? route.net_name.trim() : null) ??
|
|
||||||
(typeof route.net === "string" && route.net.trim().length > 0 ? route.net.trim() : null) ??
|
|
||||||
(typeof route.name === "string" && route.name.trim().length > 0 ? route.name.trim() : null);
|
|
||||||
|
|
||||||
const id = netNameRaw ?? `net-${index + 1}`;
|
|
||||||
const label = netNameRaw ?? `Net ${index + 1}`;
|
|
||||||
const netClass =
|
|
||||||
(typeof netRecord?.class === "string" && netRecord.class.trim().length > 0 ? netRecord.class.trim() : null) ??
|
|
||||||
(typeof route.class === "string" && route.class.trim().length > 0 ? route.class.trim() : null);
|
|
||||||
|
|
||||||
const segments = [
|
|
||||||
...getSegmentsFromUnknown(route.routes),
|
|
||||||
...getSegmentsFromUnknown(route.segments),
|
|
||||||
...getSegmentsFromUnknown(route.path),
|
|
||||||
...getSegmentsFromUnknown(route.polyline),
|
|
||||||
...getSegmentsFromUnknown(route.points)
|
|
||||||
];
|
|
||||||
if (segments.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredPoint =
|
|
||||||
asPoint(Array.isArray(route.labelPoints) ? route.labelPoints[0] : null) ??
|
|
||||||
asPoint(Array.isArray(route.tiePoints) ? route.tiePoints[0] : null) ??
|
|
||||||
asPoint(Array.isArray(route.junctionPoints) ? route.junctionPoints[0] : null);
|
|
||||||
|
|
||||||
parsed.push({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
netClass,
|
|
||||||
segments,
|
|
||||||
labelPoint: pickLabelPoint(segments, preferredPoint),
|
|
||||||
highlight: selectedNet !== null && selectedNet === id,
|
|
||||||
source: "routed"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (parsed.length > 0) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFallbackWires(
|
|
||||||
nets: Record<string, unknown>[],
|
|
||||||
placements: Map<string, NodePlacement>,
|
|
||||||
instancesByRef: Map<string, SchemetaInstance>,
|
|
||||||
symbols: SchemetaModel["symbols"],
|
|
||||||
selectedNet: string | null
|
|
||||||
): CanvasWire[] {
|
|
||||||
const wires: CanvasWire[] = [];
|
|
||||||
|
|
||||||
nets.forEach((net, index) => {
|
|
||||||
const { id, label } = toNetIdentity(net, index);
|
|
||||||
const netClass = typeof net.class === "string" && net.class.trim().length > 0 ? net.class.trim() : null;
|
|
||||||
const nodesRaw = Array.isArray(net.nodes) ? net.nodes : [];
|
|
||||||
|
|
||||||
const points = nodesRaw
|
|
||||||
.map((node) => {
|
|
||||||
const endpoint = asRecord(node);
|
|
||||||
if (!endpoint || typeof endpoint.ref !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const placement = placements.get(endpoint.ref);
|
|
||||||
const instance = instancesByRef.get(endpoint.ref);
|
|
||||||
if (!placement || !instance) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pinName = typeof endpoint.pin === "string" && endpoint.pin.trim().length > 0 ? endpoint.pin : null;
|
|
||||||
return getPinAnchor(placement, instance, symbols, pinName);
|
|
||||||
})
|
|
||||||
.filter((value): value is CanvasPoint => value !== null);
|
|
||||||
|
|
||||||
if (points.length < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = points[0];
|
|
||||||
const segments = points.slice(1).map((point) => ({ a: root, b: point }));
|
|
||||||
if (segments.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
wires.push({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
netClass,
|
|
||||||
segments,
|
|
||||||
labelPoint: pickLabelPoint(segments, null),
|
|
||||||
highlight: selectedNet !== null && selectedNet === id,
|
|
||||||
source: "fallback"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return wires;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatZoom(scale: number) {
|
function formatZoom(scale: number) {
|
||||||
return `${Math.round(scale * 100)}%`;
|
return `${Math.round(scale * 100)}%`;
|
||||||
}
|
}
|
||||||
@ -480,77 +134,13 @@ type CanvasAreaProps = {
|
|||||||
store?: SchemetaStore;
|
store?: SchemetaStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
function shouldIgnoreKeyboardTarget(target: EventTarget | null): boolean {
|
|
||||||
if (!(target instanceof HTMLElement)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const tag = target.tagName.toLowerCase();
|
|
||||||
return tag === "input" || tag === "textarea" || target.isContentEditable;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRefSelection(selectedRefs: string[], ref: string): string[] {
|
|
||||||
const set = new Set(selectedRefs);
|
|
||||||
if (set.has(ref)) {
|
|
||||||
set.delete(ref);
|
|
||||||
} else {
|
|
||||||
set.add(ref);
|
|
||||||
}
|
|
||||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRotation(value: unknown): number {
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const normalized = ((Math.round(numeric / 90) * 90) % 360 + 360) % 360;
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRefsFromModel(model: SchemetaModel, selectedRefs: string[]): SchemetaModel {
|
|
||||||
if (!Array.isArray(model.instances) || selectedRefs.length === 0) {
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
const refSet = new Set(selectedRefs);
|
|
||||||
const instances = model.instances.filter((instance) => !refSet.has(instance.ref));
|
|
||||||
|
|
||||||
const nets = Array.isArray(model.nets)
|
|
||||||
? model.nets
|
|
||||||
.map((net) => {
|
|
||||||
if (!Array.isArray(net.nodes)) {
|
|
||||||
return net;
|
|
||||||
}
|
|
||||||
const nodes = net.nodes.filter((node) => {
|
|
||||||
const endpoint = asRecord(node);
|
|
||||||
return !(endpoint && typeof endpoint.ref === "string" && refSet.has(endpoint.ref));
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...net,
|
|
||||||
nodes
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((net) => Array.isArray(net.nodes) && net.nodes.length > 1)
|
|
||||||
: model.nets;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...model,
|
|
||||||
instances,
|
|
||||||
nets
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
|
export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
|
||||||
const store = providedStore ?? fallbackStore;
|
const store = providedStore ?? fallbackStore;
|
||||||
const state = useStoreState(store);
|
const state = useStoreState(store);
|
||||||
const [drag, setDrag] = useState<DragState | null>(null);
|
const [drag, setDrag] = useState<DragState | null>(null);
|
||||||
const [boxSelect, setBoxSelect] = useState<BoxSelectState | null>(null);
|
|
||||||
|
|
||||||
const instances = state.model?.instances ?? [];
|
const instances = state.model?.instances ?? [];
|
||||||
const symbols = state.model?.symbols ?? {};
|
const symbols = state.model?.symbols ?? {};
|
||||||
const selectedRefs = state.selection.selectedRefs;
|
const selectedRef = state.selection.selectedRefs[0] ?? null;
|
||||||
const selectedSet = useMemo(() => new Set(selectedRefs), [selectedRefs]);
|
|
||||||
const selectedNet = state.selection.selectedNet;
|
|
||||||
|
|
||||||
const placements = useMemo(() => {
|
const placements = useMemo(() => {
|
||||||
const byRef = new Map<string, NodePlacement>();
|
const byRef = new Map<string, NodePlacement>();
|
||||||
@ -560,34 +150,12 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
|
|||||||
return byRef;
|
return byRef;
|
||||||
}, [instances, symbols]);
|
}, [instances, symbols]);
|
||||||
|
|
||||||
const instancesByRef = useMemo(() => {
|
function setSelection(ref: string | null) {
|
||||||
return new Map(instances.map((instance) => [instance.ref, instance]));
|
|
||||||
}, [instances]);
|
|
||||||
|
|
||||||
const modelNets = useMemo(
|
|
||||||
() =>
|
|
||||||
(state.model?.nets ?? []).filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null),
|
|
||||||
[state.model?.nets]
|
|
||||||
);
|
|
||||||
|
|
||||||
const routedWires = useMemo(() => extractRoutedWires(state.compileResult, selectedNet), [state.compileResult, selectedNet]);
|
|
||||||
|
|
||||||
const fallbackWires = useMemo(
|
|
||||||
() => buildFallbackWires(modelNets, placements, instancesByRef, symbols, selectedNet),
|
|
||||||
[instancesByRef, modelNets, placements, selectedNet, symbols]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasRoutedWires = routedWires.length > 0;
|
|
||||||
const visibleWires = hasRoutedWires ? routedWires : fallbackWires;
|
|
||||||
const hasSelectedNet = selectedNet !== null;
|
|
||||||
|
|
||||||
function clearSelection() {
|
|
||||||
store.actions.setSelection({
|
store.actions.setSelection({
|
||||||
selectedRefs: [],
|
selectedRefs: ref ? [ref] : [],
|
||||||
selectedNet: null,
|
selectedNet: null,
|
||||||
selectedPin: null
|
selectedPin: null
|
||||||
});
|
});
|
||||||
store.actions.setUiFlags({ isolateComponent: false, isolateNet: false });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScale(nextScale: number) {
|
function updateScale(nextScale: number) {
|
||||||
@ -595,339 +163,56 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
|
|||||||
store.actions.setViewport({ scale: clamped });
|
store.actions.setViewport({ scale: clamped });
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventToCanvasPoint(event: ReactPointerEvent<HTMLElement>): CanvasPoint {
|
|
||||||
const surface = event.currentTarget;
|
|
||||||
const rect = surface.getBoundingClientRect();
|
|
||||||
const localX = event.clientX - rect.left + surface.scrollLeft;
|
|
||||||
const localY = event.clientY - rect.top + surface.scrollTop;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: (localX - state.viewport.panX) / state.viewport.scale,
|
|
||||||
y: (localY - state.viewport.panY) / state.viewport.scale
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginDrag(refs: string[], event: ReactPointerEvent<HTMLDivElement>) {
|
|
||||||
const starts = new Map<string, { x: number; y: number }>();
|
|
||||||
refs.forEach((ref) => {
|
|
||||||
const placement = placements.get(ref);
|
|
||||||
if (!placement || placement.locked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
starts.set(ref, { x: placement.x, y: placement.y });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (starts.size === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.actions.beginTransaction(starts.size > 1 ? "drag components" : "drag component");
|
|
||||||
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
setDrag({
|
|
||||||
refs: Array.from(starts.keys()),
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
startClientX: event.clientX,
|
|
||||||
startClientY: event.clientY,
|
|
||||||
starts
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCanvasPointerDown(event: ReactPointerEvent<HTMLElement>) {
|
function handleCanvasPointerDown(event: ReactPointerEvent<HTMLElement>) {
|
||||||
if (event.button !== 0 || event.target !== event.currentTarget) {
|
if (event.target === event.currentTarget) {
|
||||||
return;
|
setSelection(null);
|
||||||
}
|
|
||||||
|
|
||||||
event.currentTarget.focus();
|
|
||||||
|
|
||||||
const additive = event.shiftKey || event.metaKey || event.ctrlKey;
|
|
||||||
if (!additive) {
|
|
||||||
clearSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
const point = eventToCanvasPoint(event);
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
setBoxSelect({
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
start: point,
|
|
||||||
current: point
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCanvasPointerMove(event: ReactPointerEvent<HTMLElement>) {
|
|
||||||
if (!boxSelect || boxSelect.pointerId !== event.pointerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
const point = eventToCanvasPoint(event);
|
|
||||||
setBoxSelect((prev) => {
|
|
||||||
if (!prev) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
current: point
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCanvasPointerUpOrCancel(event: ReactPointerEvent<HTMLElement>) {
|
|
||||||
if (!boxSelect || boxSelect.pointerId !== event.pointerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const additive = event.shiftKey || event.metaKey || event.ctrlKey;
|
|
||||||
const minX = Math.min(boxSelect.start.x, boxSelect.current.x);
|
|
||||||
const minY = Math.min(boxSelect.start.y, boxSelect.current.y);
|
|
||||||
const maxX = Math.max(boxSelect.start.x, boxSelect.current.x);
|
|
||||||
const maxY = Math.max(boxSelect.start.y, boxSelect.current.y);
|
|
||||||
const width = maxX - minX;
|
|
||||||
const height = maxY - minY;
|
|
||||||
|
|
||||||
const nextSelected =
|
|
||||||
width < 4 && height < 4
|
|
||||||
? []
|
|
||||||
: instances
|
|
||||||
.map((instance, index) => ({ ref: instance.ref, placement: placements.get(instance.ref) ?? getNodePlacement(instance, index, symbols) }))
|
|
||||||
.filter((entry) => {
|
|
||||||
const nodeMinX = entry.placement.x;
|
|
||||||
const nodeMinY = entry.placement.y;
|
|
||||||
const nodeMaxX = entry.placement.x + entry.placement.width;
|
|
||||||
const nodeMaxY = entry.placement.y + entry.placement.height;
|
|
||||||
return !(nodeMaxX < minX || nodeMinX > maxX || nodeMaxY < minY || nodeMinY > maxY);
|
|
||||||
})
|
|
||||||
.map((entry) => entry.ref)
|
|
||||||
.sort((a, b) => a.localeCompare(b));
|
|
||||||
|
|
||||||
if (nextSelected.length > 0 || !additive) {
|
|
||||||
const merged = additive ? Array.from(new Set([...state.selection.selectedRefs, ...nextSelected])) : nextSelected;
|
|
||||||
store.actions.setSelection({
|
|
||||||
selectedRefs: merged.sort((a, b) => a.localeCompare(b)),
|
|
||||||
selectedNet: null,
|
|
||||||
selectedPin: null
|
|
||||||
});
|
|
||||||
if (merged.length > 0) {
|
|
||||||
store.actions.setUiFlags({ isolateComponent: false, isolateNet: false });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setBoxSelect(null);
|
function handleNodePointerDown(event: ReactPointerEvent<HTMLDivElement>, ref: string, placement: NodePlacement) {
|
||||||
}
|
|
||||||
|
|
||||||
function handleNodePointerDown(event: ReactPointerEvent<HTMLDivElement>, ref: string) {
|
|
||||||
if (event.button !== 0) {
|
if (event.button !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.stopPropagation();
|
setSelection(ref);
|
||||||
event.currentTarget.focus();
|
if (placement.locked) {
|
||||||
|
|
||||||
const additive = event.shiftKey || event.metaKey || event.ctrlKey;
|
|
||||||
if (additive) {
|
|
||||||
store.actions.setSelection({
|
|
||||||
selectedRefs: toggleRefSelection(state.selection.selectedRefs, ref),
|
|
||||||
selectedNet: null,
|
|
||||||
selectedPin: null
|
|
||||||
});
|
|
||||||
store.actions.setUiFlags({ isolateComponent: false, isolateNet: false });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlreadySelected = selectedSet.has(ref);
|
event.stopPropagation();
|
||||||
const dragCandidates = isAlreadySelected && selectedRefs.length > 1 ? selectedRefs : [ref];
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
setDrag({
|
||||||
if (!isAlreadySelected || selectedRefs.length !== dragCandidates.length) {
|
ref,
|
||||||
store.actions.setSelection({
|
pointerId: event.pointerId,
|
||||||
selectedRefs: dragCandidates,
|
startClientX: event.clientX,
|
||||||
selectedNet: null,
|
startClientY: event.clientY,
|
||||||
selectedPin: null
|
startX: placement.x,
|
||||||
|
startY: placement.y
|
||||||
});
|
});
|
||||||
store.actions.setUiFlags({ isolateComponent: false, isolateNet: false });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beginDrag(dragCandidates, event);
|
function handleNodePointerMove(event: ReactPointerEvent<HTMLDivElement>, ref: string) {
|
||||||
}
|
if (!drag || drag.ref !== ref || drag.pointerId !== event.pointerId) {
|
||||||
|
|
||||||
function handleNodePointerMove(event: ReactPointerEvent<HTMLDivElement>) {
|
|
||||||
if (!drag || drag.pointerId !== event.pointerId) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const dx = (event.clientX - drag.startClientX) / state.viewport.scale;
|
const dx = (event.clientX - drag.startClientX) / state.viewport.scale;
|
||||||
const dy = (event.clientY - drag.startClientY) / state.viewport.scale;
|
const dy = (event.clientY - drag.startClientY) / state.viewport.scale;
|
||||||
|
store.actions.moveComponent(ref, { x: drag.startX + dx, y: drag.startY + dy });
|
||||||
drag.refs.forEach((ref) => {
|
|
||||||
const origin = drag.starts.get(ref);
|
|
||||||
if (!origin) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
store.actions.moveComponent(ref, { x: origin.x + dx, y: origin.y + dy });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNodePointerUpOrCancel(event: ReactPointerEvent<HTMLDivElement>) {
|
function handleNodePointerUpOrCancel(event: ReactPointerEvent<HTMLDivElement>, ref: string) {
|
||||||
if (!drag || drag.pointerId !== event.pointerId) {
|
if (!drag || drag.ref !== ref || drag.pointerId !== event.pointerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setDrag(null);
|
setDrag(null);
|
||||||
store.actions.commitTransaction();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateSelected() {
|
|
||||||
if (selectedRefs.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const refs = selectedRefs.filter((ref) => {
|
|
||||||
const placement = placements.get(ref);
|
|
||||||
return Boolean(placement && !placement.locked);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (refs.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.actions.beginTransaction(refs.length > 1 ? "rotate components" : "rotate component");
|
|
||||||
refs.forEach((ref) => {
|
|
||||||
const instance = instancesByRef.get(ref);
|
|
||||||
const current = normalizeRotation(instance?.placement?.rotation ?? 0);
|
|
||||||
store.actions.moveComponent(ref, { rotation: normalizeRotation(current + 90) });
|
|
||||||
});
|
|
||||||
store.actions.commitTransaction();
|
|
||||||
}
|
|
||||||
|
|
||||||
function nudgeSelected(dx: number, dy: number) {
|
|
||||||
if (selectedRefs.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const refs = selectedRefs.filter((ref) => {
|
|
||||||
const placement = placements.get(ref);
|
|
||||||
return Boolean(placement && !placement.locked);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (refs.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.actions.beginTransaction(refs.length > 1 ? "nudge components" : "nudge component");
|
|
||||||
refs.forEach((ref) => {
|
|
||||||
const placement = placements.get(ref);
|
|
||||||
if (!placement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
store.actions.moveComponent(ref, { x: placement.x + dx, y: placement.y + dy });
|
|
||||||
});
|
|
||||||
store.actions.commitTransaction();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteSelected() {
|
|
||||||
if (!state.model || selectedRefs.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextModel = removeRefsFromModel(state.model, selectedRefs);
|
|
||||||
store.actions.beginTransaction(selectedRefs.length > 1 ? "delete components" : "delete component");
|
|
||||||
store.actions.setModel(nextModel);
|
|
||||||
clearSelection();
|
|
||||||
store.actions.commitTransaction();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCanvasKeyDown(event: ReactKeyboardEvent<HTMLElement>) {
|
|
||||||
if (shouldIgnoreKeyboardTarget(event.target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasCommand = event.ctrlKey || event.metaKey;
|
|
||||||
if (hasCommand && event.key.toLowerCase() === "a") {
|
|
||||||
event.preventDefault();
|
|
||||||
store.actions.setSelection({
|
|
||||||
selectedRefs: instances.map((instance) => instance.ref).sort((a, b) => a.localeCompare(b)),
|
|
||||||
selectedNet: null,
|
|
||||||
selectedPin: null
|
|
||||||
});
|
|
||||||
store.actions.setUiFlags({ isolateComponent: false, isolateNet: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
clearSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Delete" || event.key === "Backspace") {
|
|
||||||
event.preventDefault();
|
|
||||||
deleteSelected();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.code === "Space") {
|
|
||||||
event.preventDefault();
|
|
||||||
rotateSelected();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = event.shiftKey ? NUDGE_STEP_FAST : NUDGE_STEP;
|
|
||||||
switch (event.key) {
|
|
||||||
case "ArrowUp":
|
|
||||||
event.preventDefault();
|
|
||||||
nudgeSelected(0, -step);
|
|
||||||
break;
|
|
||||||
case "ArrowDown":
|
|
||||||
event.preventDefault();
|
|
||||||
nudgeSelected(0, step);
|
|
||||||
break;
|
|
||||||
case "ArrowLeft":
|
|
||||||
event.preventDefault();
|
|
||||||
nudgeSelected(-step, 0);
|
|
||||||
break;
|
|
||||||
case "ArrowRight":
|
|
||||||
event.preventDefault();
|
|
||||||
nudgeSelected(step, 0);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectNet(netId: string | null) {
|
|
||||||
store.actions.setSelection({
|
|
||||||
selectedRefs: [],
|
|
||||||
selectedNet: netId,
|
|
||||||
selectedPin: null
|
|
||||||
});
|
|
||||||
store.actions.setUiFlags({ isolateComponent: false, isolateNet: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectionBoxStyle = useMemo(() => {
|
|
||||||
if (!boxSelect) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const left = Math.min(boxSelect.start.x, boxSelect.current.x);
|
|
||||||
const top = Math.min(boxSelect.start.y, boxSelect.current.y);
|
|
||||||
const width = Math.max(1, Math.abs(boxSelect.current.x - boxSelect.start.x));
|
|
||||||
const height = Math.max(1, Math.abs(boxSelect.current.y - boxSelect.start.y));
|
|
||||||
return {
|
|
||||||
left: `${left}px`,
|
|
||||||
top: `${top}px`,
|
|
||||||
width: `${width}px`,
|
|
||||||
height: `${height}px`
|
|
||||||
};
|
|
||||||
}, [boxSelect]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="canvas" aria-label="Canvas area">
|
<main className="canvas" aria-label="Canvas area">
|
||||||
<div className="canvas__toolbar" role="toolbar" aria-label="Canvas controls">
|
<div className="canvas__toolbar" role="toolbar" aria-label="Canvas controls">
|
||||||
@ -959,75 +244,16 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section
|
<section className="canvas__surface" onPointerDown={handleCanvasPointerDown}>
|
||||||
className="canvas__surface"
|
|
||||||
onPointerDown={handleCanvasPointerDown}
|
|
||||||
onPointerMove={handleCanvasPointerMove}
|
|
||||||
onPointerUp={handleCanvasPointerUpOrCancel}
|
|
||||||
onPointerCancel={handleCanvasPointerUpOrCancel}
|
|
||||||
onKeyDown={handleCanvasKeyDown}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="canvas__viewport"
|
className="canvas__viewport"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${state.viewport.panX}px, ${state.viewport.panY}px) scale(${state.viewport.scale})`
|
transform: `translate(${state.viewport.panX}px, ${state.viewport.panY}px) scale(${state.viewport.scale})`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg className="canvas-wires" aria-hidden="true" viewBox="0 0 2200 1500" preserveAspectRatio="none">
|
|
||||||
{visibleWires.map((wire) =>
|
|
||||||
wire.segments.map((segment, segmentIndex) => {
|
|
||||||
const isDimmed = hasSelectedNet && !wire.highlight;
|
|
||||||
return (
|
|
||||||
<line
|
|
||||||
key={`${wire.id}-${segmentIndex}-${segment.a.x}-${segment.a.y}-${segment.b.x}-${segment.b.y}`}
|
|
||||||
x1={segment.a.x}
|
|
||||||
y1={segment.a.y}
|
|
||||||
x2={segment.b.x}
|
|
||||||
y2={segment.b.y}
|
|
||||||
className={`canvas-wire-segment ${wire.highlight ? "is-highlight" : ""} ${isDimmed ? "is-dimmed" : ""}`}
|
|
||||||
data-net-id={wire.id}
|
|
||||||
data-wire-source={wire.source}
|
|
||||||
data-net-class={wire.netClass ?? undefined}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
selectNet(wire.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{state.uiFlags.showLabels
|
|
||||||
? visibleWires.map((wire) => {
|
|
||||||
if (!wire.labelPoint) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const isDimmed = hasSelectedNet && !wire.highlight;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={`label-${wire.id}`}
|
|
||||||
className={`canvas-wire-label ${wire.highlight ? "is-highlight" : ""} ${isDimmed ? "is-dimmed" : ""}`}
|
|
||||||
style={{ left: `${wire.labelPoint.x}px`, top: `${wire.labelPoint.y}px` }}
|
|
||||||
data-net-id={wire.id}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
selectNet(wire.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{wire.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: null}
|
|
||||||
|
|
||||||
{selectionBoxStyle ? <div className="canvas__selection-box" style={selectionBoxStyle} /> : null}
|
|
||||||
|
|
||||||
{instances.map((instance, index) => {
|
{instances.map((instance, index) => {
|
||||||
const placement = placements.get(instance.ref) ?? getNodePlacement(instance, index, symbols);
|
const placement = placements.get(instance.ref) ?? getNodePlacement(instance, index, symbols);
|
||||||
const isSelected = selectedSet.has(instance.ref);
|
const isSelected = selectedRef === instance.ref;
|
||||||
const valueLabel =
|
const valueLabel =
|
||||||
typeof instance.properties?.value === "string" && instance.properties.value.length > 0
|
typeof instance.properties?.value === "string" && instance.properties.value.length > 0
|
||||||
? instance.properties.value
|
? instance.properties.value
|
||||||
@ -1036,13 +262,12 @@ export function CanvasArea({ store: providedStore }: CanvasAreaProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={instance.ref}
|
key={instance.ref}
|
||||||
className={`canvas-node ${isSelected ? "is-selected" : ""} ${drag?.refs.includes(instance.ref) ? "is-dragging" : ""}`}
|
className={`canvas-node ${isSelected ? "is-selected" : ""} ${drag?.ref === instance.ref ? "is-dragging" : ""}`}
|
||||||
style={{ left: `${placement.x}px`, top: `${placement.y}px`, width: `${placement.width}px`, minHeight: `${placement.height}px` }}
|
style={{ left: `${placement.x}px`, top: `${placement.y}px`, width: `${placement.width}px`, minHeight: `${placement.height}px` }}
|
||||||
onPointerDown={(event) => handleNodePointerDown(event, instance.ref)}
|
onPointerDown={(event) => handleNodePointerDown(event, instance.ref, placement)}
|
||||||
onPointerMove={handleNodePointerMove}
|
onPointerMove={(event) => handleNodePointerMove(event, instance.ref)}
|
||||||
onPointerUp={handleNodePointerUpOrCancel}
|
onPointerUp={(event) => handleNodePointerUpOrCancel(event, instance.ref)}
|
||||||
onPointerCancel={handleNodePointerUpOrCancel}
|
onPointerCancel={(event) => handleNodePointerUpOrCancel(event, instance.ref)}
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<div className="canvas-node__chrome" style={{ transform: `rotate(${placement.rotation}deg)` }}>
|
<div className="canvas-node__chrome" style={{ transform: `rotate(${placement.rotation}deg)` }}>
|
||||||
<div className="canvas-node__content" style={{ transform: `rotate(${-placement.rotation}deg)` }}>
|
<div className="canvas-node__content" style={{ transform: `rotate(${-placement.rotation}deg)` }}>
|
||||||
|
|||||||
@ -242,19 +242,6 @@ body {
|
|||||||
background-size: 28px 28px;
|
background-size: 28px 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas__surface:focus-visible {
|
|
||||||
outline: 2px solid #0a79c2;
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas__selection-box {
|
|
||||||
position: absolute;
|
|
||||||
border: 1px solid #0a79c2;
|
|
||||||
background: rgba(10, 121, 194, 0.14);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas__viewport {
|
.canvas__viewport {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 2200px;
|
width: 2200px;
|
||||||
@ -262,63 +249,6 @@ body {
|
|||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-wires {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-wire-segment {
|
|
||||||
stroke: #5a6e7f;
|
|
||||||
stroke-width: 2.2px;
|
|
||||||
stroke-linecap: round;
|
|
||||||
opacity: 0.9;
|
|
||||||
pointer-events: stroke;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-wire-segment.is-highlight {
|
|
||||||
stroke: #0b78bf;
|
|
||||||
stroke-width: 3px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-wire-segment.is-dimmed {
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-wire-label {
|
|
||||||
position: absolute;
|
|
||||||
transform: translate(-50%, calc(-100% - 6px));
|
|
||||||
padding: 0.1rem 0.3rem;
|
|
||||||
border: 1px solid rgba(144, 158, 171, 0.8);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
color: #243f53;
|
|
||||||
font-size: 0.66rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
white-space: nowrap;
|
|
||||||
pointer-events: auto;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.canvas-wire-label {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-wire-label.is-highlight {
|
|
||||||
border-color: #0b78bf;
|
|
||||||
color: #0d4f7b;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-wire-label.is-dimmed {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-node {
|
.canvas-node {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { analyzeModel } from "./analyze.js";
|
import { analyzeModel } from "./analyze.js";
|
||||||
import { DEFAULT_LAYOUT_ENGINE, layoutAndRoute, requestedLayoutEngine } from "./layout.js";
|
import { layoutAndRoute } from "./layout.js";
|
||||||
import { renderSvgFromLayout } from "./render.js";
|
import { renderSvgFromLayout } from "./render.js";
|
||||||
import { validateModel } from "./validate.js";
|
import { validateModel } from "./validate.js";
|
||||||
|
|
||||||
@ -239,7 +239,6 @@ function annotateIssues(issues, prefix) {
|
|||||||
|
|
||||||
export function compile(payload, options = {}) {
|
export function compile(payload, options = {}) {
|
||||||
const validated = validateModel(payload, options);
|
const validated = validateModel(payload, options);
|
||||||
const layoutEngineRequested = requestedLayoutEngine(options);
|
|
||||||
|
|
||||||
if (!validated.model) {
|
if (!validated.model) {
|
||||||
const errors = annotateIssues(validated.issues.filter((x) => x.severity === "error"), "E");
|
const errors = annotateIssues(validated.issues.filter((x) => x.severity === "error"), "E");
|
||||||
@ -267,9 +266,6 @@ export function compile(payload, options = {}) {
|
|||||||
bus_groups: [],
|
bus_groups: [],
|
||||||
focus_map: {},
|
focus_map: {},
|
||||||
render_mode_used: options.render_mode ?? "schematic_stub",
|
render_mode_used: options.render_mode ?? "schematic_stub",
|
||||||
layout_engine_requested: layoutEngineRequested,
|
|
||||||
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
|
|
||||||
layout_warnings: [],
|
|
||||||
svg: ""
|
svg: ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -304,9 +300,6 @@ export function compile(payload, options = {}) {
|
|||||||
layout_metrics: layout.metrics,
|
layout_metrics: layout.metrics,
|
||||||
bus_groups: layout.bus_groups,
|
bus_groups: layout.bus_groups,
|
||||||
render_mode_used: layout.render_mode_used,
|
render_mode_used: layout.render_mode_used,
|
||||||
layout_engine_requested: layout.layout_engine_requested ?? layoutEngineRequested,
|
|
||||||
layout_engine_used: layout.layout_engine_used ?? DEFAULT_LAYOUT_ENGINE,
|
|
||||||
layout_warnings: Array.isArray(layout.layout_warnings) ? layout.layout_warnings : [],
|
|
||||||
svg
|
svg
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
import { createRequire } from "node:module";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const DEFAULT_ELK_MODULE = "elkjs/lib/elk.bundled.js";
|
|
||||||
const runtimeCache = new Map();
|
|
||||||
|
|
||||||
function normalizeElkExport(mod) {
|
|
||||||
if (!mod) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof mod === "function") {
|
|
||||||
return mod;
|
|
||||||
}
|
|
||||||
if (typeof mod.default === "function") {
|
|
||||||
return mod.default;
|
|
||||||
}
|
|
||||||
if (typeof mod.ELK === "function") {
|
|
||||||
return mod.ELK;
|
|
||||||
}
|
|
||||||
if (mod.default && typeof mod.default.ELK === "function") {
|
|
||||||
return mod.default.ELK;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveElkRuntime(moduleId = DEFAULT_ELK_MODULE) {
|
|
||||||
const key = String(moduleId || DEFAULT_ELK_MODULE);
|
|
||||||
if (runtimeCache.has(key)) {
|
|
||||||
return runtimeCache.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
let state;
|
|
||||||
try {
|
|
||||||
const loaded = require(key);
|
|
||||||
const ElkCtor = normalizeElkExport(loaded);
|
|
||||||
if (!ElkCtor) {
|
|
||||||
state = {
|
|
||||||
ok: false,
|
|
||||||
module: key,
|
|
||||||
reason: "invalid_export",
|
|
||||||
message: `ELK module "${key}" loaded but did not expose a usable constructor.`
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
state = {
|
|
||||||
ok: true,
|
|
||||||
module: key,
|
|
||||||
ElkCtor
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
state = {
|
|
||||||
ok: false,
|
|
||||||
module: key,
|
|
||||||
reason: "module_load_failed",
|
|
||||||
message: `ELK module "${key}" unavailable: ${message}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
runtimeCache.set(key, state);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import { resolveElkRuntime } from "./layout-elk.js";
|
|
||||||
|
|
||||||
const GRID = 20;
|
const GRID = 20;
|
||||||
const MARGIN_X = 140;
|
const MARGIN_X = 140;
|
||||||
const MARGIN_Y = 140;
|
const MARGIN_Y = 140;
|
||||||
@ -21,7 +19,6 @@ const NET_CLASS_PRIORITY = {
|
|||||||
|
|
||||||
const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]);
|
const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]);
|
||||||
const DEFAULT_RENDER_MODE = "schematic_stub";
|
const DEFAULT_RENDER_MODE = "schematic_stub";
|
||||||
export const DEFAULT_LAYOUT_ENGINE = "schemeta-v2";
|
|
||||||
const ROTATION_STEPS = [0, 90, 180, 270];
|
const ROTATION_STEPS = [0, 90, 180, 270];
|
||||||
const MIN_CHANNEL_SPACING_STEPS = 3;
|
const MIN_CHANNEL_SPACING_STEPS = 3;
|
||||||
const LANE_ORDER = ["power", "clock", "signal", "analog", "ground", "bus", "differential"];
|
const LANE_ORDER = ["power", "clock", "signal", "analog", "ground", "bus", "differential"];
|
||||||
@ -2392,15 +2389,7 @@ export function applyLayoutToModel(model, options = {}) {
|
|||||||
return working;
|
return working;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requestedLayoutEngine(options = {}) {
|
export function layoutAndRoute(model, options = {}) {
|
||||||
const explicitEngine = typeof options.layout_engine === "string" ? options.layout_engine.trim().toLowerCase() : "";
|
|
||||||
if (explicitEngine === "elk" || options.use_elk_layout === true) {
|
|
||||||
return "elk";
|
|
||||||
}
|
|
||||||
return DEFAULT_LAYOUT_ENGINE;
|
|
||||||
}
|
|
||||||
|
|
||||||
function layoutAndRouteNative(model, options = {}) {
|
|
||||||
const renderMode = options.render_mode === "explicit" ? "explicit" : DEFAULT_RENDER_MODE;
|
const renderMode = options.render_mode === "explicit" ? "explicit" : DEFAULT_RENDER_MODE;
|
||||||
const respectLocks = options.respect_locks ?? true;
|
const respectLocks = options.respect_locks ?? true;
|
||||||
const autoRotate = options.auto_rotate ?? true;
|
const autoRotate = options.auto_rotate ?? true;
|
||||||
@ -2425,48 +2414,6 @@ function layoutAndRouteNative(model, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function layoutAndRoute(model, options = {}) {
|
|
||||||
const requestedEngine = requestedLayoutEngine(options);
|
|
||||||
const nativeLayout = layoutAndRouteNative(model, options);
|
|
||||||
|
|
||||||
if (requestedEngine !== "elk") {
|
|
||||||
return {
|
|
||||||
...nativeLayout,
|
|
||||||
layout_engine_requested: requestedEngine,
|
|
||||||
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
|
|
||||||
layout_warnings: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const elkRuntime = resolveElkRuntime(options.elk_runtime_module);
|
|
||||||
if (!elkRuntime.ok) {
|
|
||||||
return {
|
|
||||||
...nativeLayout,
|
|
||||||
layout_engine_requested: "elk",
|
|
||||||
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
|
|
||||||
layout_warnings: [
|
|
||||||
{
|
|
||||||
code: "elk_layout_unavailable_fallback",
|
|
||||||
message: elkRuntime.message
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...nativeLayout,
|
|
||||||
layout_engine_requested: "elk",
|
|
||||||
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
|
|
||||||
layout_warnings: [
|
|
||||||
{
|
|
||||||
code: "elk_layout_boundary_fallback",
|
|
||||||
message:
|
|
||||||
"ELK runtime resolved, but backend ELK placement is not yet enabled. Using default layout engine."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function netAnchorPoint(net, model, placed) {
|
export function netAnchorPoint(net, model, placed) {
|
||||||
const first = net.nodes[0];
|
const first = net.nodes[0];
|
||||||
if (!first) {
|
if (!first) {
|
||||||
|
|||||||
126
src/render.js
@ -191,39 +191,7 @@ function distance(a, b) {
|
|||||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rectFromLabelPoint(point, width = 78, height = 14) {
|
function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
|
||||||
return {
|
|
||||||
x: point.x - 2,
|
|
||||||
y: point.y - height + 2,
|
|
||||||
width,
|
|
||||||
height
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rectsOverlap(a, b) {
|
|
||||||
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointInRect(point, rect) {
|
|
||||||
return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withCandidateOffsets(point) {
|
|
||||||
const offsets = [
|
|
||||||
[0, 0],
|
|
||||||
[0, -10],
|
|
||||||
[0, 10],
|
|
||||||
[10, 0],
|
|
||||||
[-10, 0],
|
|
||||||
[14, -10],
|
|
||||||
[-14, -10],
|
|
||||||
[14, 10],
|
|
||||||
[-14, 10]
|
|
||||||
];
|
|
||||||
return offsets.map(([dx, dy]) => ({ x: point.x + dx, y: point.y + dy }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickLabelPoints(points, maxCount, used, usedRects, minSpacing, avoidPoints = [], blockedRects = []) {
|
|
||||||
const accepted = [];
|
const accepted = [];
|
||||||
|
|
||||||
for (const p of points) {
|
for (const p of points) {
|
||||||
@ -231,11 +199,9 @@ function pickLabelPoints(points, maxCount, used, usedRects, minSpacing, avoidPoi
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = withCandidateOffsets(p);
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
let blocked = false;
|
let blocked = false;
|
||||||
for (const prev of used) {
|
for (const prev of used) {
|
||||||
if (distance(candidate, prev) < minSpacing) {
|
if (distance(p, prev) < minSpacing) {
|
||||||
blocked = true;
|
blocked = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -245,7 +211,7 @@ function pickLabelPoints(points, maxCount, used, usedRects, minSpacing, avoidPoi
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const pin of avoidPoints) {
|
for (const pin of avoidPoints) {
|
||||||
if (distance(candidate, pin) < minSpacing * 0.9) {
|
if (distance(p, pin) < minSpacing * 0.9) {
|
||||||
blocked = true;
|
blocked = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -254,32 +220,8 @@ function pickLabelPoints(points, maxCount, used, usedRects, minSpacing, avoidPoi
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelRect = rectFromLabelPoint(candidate);
|
accepted.push(p);
|
||||||
for (const usedRect of usedRects) {
|
used.push(p);
|
||||||
if (rectsOverlap(labelRect, usedRect)) {
|
|
||||||
blocked = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (blocked) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const blockedRect of blockedRects) {
|
|
||||||
if (rectsOverlap(labelRect, blockedRect) || pointInRect(candidate, blockedRect)) {
|
|
||||||
blocked = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (blocked) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
accepted.push(candidate);
|
|
||||||
used.push(candidate);
|
|
||||||
usedRects.push(labelRect);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return accepted;
|
return accepted;
|
||||||
@ -367,7 +309,6 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
const pinNets = pinNetMap(model);
|
const pinNets = pinNetMap(model);
|
||||||
const netClassByName = new Map((model.nets ?? []).map((n) => [n.name, n.class]));
|
const netClassByName = new Map((model.nets ?? []).map((n) => [n.name, n.class]));
|
||||||
const allPinPoints = [];
|
const allPinPoints = [];
|
||||||
const componentRects = [];
|
|
||||||
|
|
||||||
const components = layout.placed
|
const components = layout.placed
|
||||||
.map((inst) => {
|
.map((inst) => {
|
||||||
@ -377,12 +318,6 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
const rotation = normalizeRotation(inst.placement.rotation ?? 0);
|
const rotation = normalizeRotation(inst.placement.rotation ?? 0);
|
||||||
const cx = x + sym.body.width / 2;
|
const cx = x + sym.body.width / 2;
|
||||||
const cy = y + sym.body.height / 2;
|
const cy = y + sym.body.height / 2;
|
||||||
componentRects.push({
|
|
||||||
x: x - 6,
|
|
||||||
y: y - 6,
|
|
||||||
width: sym.body.width + 12,
|
|
||||||
height: sym.body.height + 12
|
|
||||||
});
|
|
||||||
const templateKind = symbolTemplateKind(sym);
|
const templateKind = symbolTemplateKind(sym);
|
||||||
const compactLabel = templateKind || sym.body.width <= 140 || sym.body.height <= 90;
|
const compactLabel = templateKind || sym.body.width <= 140 || sym.body.height <= 90;
|
||||||
const legacyShowInstanceNetLabels = Boolean(inst.properties?.show_net_labels);
|
const legacyShowInstanceNetLabels = Boolean(inst.properties?.show_net_labels);
|
||||||
@ -536,20 +471,8 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
|
|
||||||
const routedByName = new Map(layout.routed.map((r) => [r.net.name, r]));
|
const routedByName = new Map(layout.routed.map((r) => [r.net.name, r]));
|
||||||
const usedLabelPoints = [];
|
const usedLabelPoints = [];
|
||||||
const usedLabelRects = [];
|
|
||||||
const labels = [];
|
const labels = [];
|
||||||
const tieLabels = [];
|
const tieLabels = [];
|
||||||
const blockedLabelRects = [{ x: 6, y: 6, width: 126, height: 86 }, ...componentRects];
|
|
||||||
const annotationEntries = (model.annotations ?? []).map((a, idx) => {
|
|
||||||
const x = a.x ?? 16;
|
|
||||||
const y = a.y ?? 24 + idx * 16;
|
|
||||||
const yAdjusted = x < 170 && y < 110 ? 110 + idx * 16 : y;
|
|
||||||
const text = String(a.text ?? "");
|
|
||||||
return { x, y: yAdjusted, text, width: Math.max(90, Math.min(640, text.length * 6.2)), height: 14 };
|
|
||||||
});
|
|
||||||
for (const ann of annotationEntries) {
|
|
||||||
blockedLabelRects.push({ x: ann.x - 4, y: ann.y - 12, width: ann.width + 8, height: ann.height + 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const net of model.nets) {
|
for (const net of model.nets) {
|
||||||
if (isGroundLikeNet(net)) {
|
if (isGroundLikeNet(net)) {
|
||||||
@ -566,15 +489,7 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
|
|
||||||
if (routeInfo?.mode === "label_tie") {
|
if (routeInfo?.mode === "label_tie") {
|
||||||
candidates.push(...(routeInfo?.labelPoints ?? []));
|
candidates.push(...(routeInfo?.labelPoints ?? []));
|
||||||
const selected = pickLabelPoints(
|
const selected = pickLabelPoints(candidates, 1, usedLabelPoints, GRID * 2.4, allPinPoints);
|
||||||
candidates,
|
|
||||||
1,
|
|
||||||
usedLabelPoints,
|
|
||||||
usedLabelRects,
|
|
||||||
GRID * 2.4,
|
|
||||||
allPinPoints,
|
|
||||||
blockedLabelRects
|
|
||||||
);
|
|
||||||
for (const p of selected) {
|
for (const p of selected) {
|
||||||
labels.push(renderNetLabel(p.x, p.y, net.name, net.class, true));
|
labels.push(renderNetLabel(p.x, p.y, net.name, net.class, true));
|
||||||
}
|
}
|
||||||
@ -588,15 +503,7 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
candidates.push({ x: netAnchor.x + 8, y: netAnchor.y - 8 });
|
candidates.push({ x: netAnchor.x + 8, y: netAnchor.y - 8 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = pickLabelPoints(
|
const selected = pickLabelPoints(candidates, 1, usedLabelPoints, GRID * 2.4, allPinPoints);
|
||||||
candidates,
|
|
||||||
1,
|
|
||||||
usedLabelPoints,
|
|
||||||
usedLabelRects,
|
|
||||||
GRID * 2.4,
|
|
||||||
allPinPoints,
|
|
||||||
blockedLabelRects
|
|
||||||
);
|
|
||||||
for (const p of selected) {
|
for (const p of selected) {
|
||||||
labels.push(renderNetLabel(p.x, p.y, net.name, net.class));
|
labels.push(renderNetLabel(p.x, p.y, net.name, net.class));
|
||||||
}
|
}
|
||||||
@ -604,7 +511,6 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
|
|
||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
const usedTieLabels = [];
|
const usedTieLabels = [];
|
||||||
const usedTieRects = [];
|
|
||||||
for (const rn of layout.routed) {
|
for (const rn of layout.routed) {
|
||||||
if (rn.mode !== "label_tie" || isGroundLikeNet(rn.net)) {
|
if (rn.mode !== "label_tie" || isGroundLikeNet(rn.net)) {
|
||||||
continue;
|
continue;
|
||||||
@ -616,15 +522,7 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxPerNet = rn.net.class === "power" ? Math.min(6, candidates.length) : Math.min(2, candidates.length);
|
const maxPerNet = rn.net.class === "power" ? Math.min(6, candidates.length) : Math.min(2, candidates.length);
|
||||||
const selected = pickLabelPoints(
|
const selected = pickLabelPoints(candidates, maxPerNet, usedTieLabels, GRID * 1.5, allPinPoints);
|
||||||
candidates,
|
|
||||||
maxPerNet,
|
|
||||||
usedTieLabels,
|
|
||||||
usedTieRects,
|
|
||||||
GRID * 1.5,
|
|
||||||
allPinPoints,
|
|
||||||
blockedLabelRects
|
|
||||||
);
|
|
||||||
for (const p of selected) {
|
for (const p of selected) {
|
||||||
tieLabels.push(renderNetLabel(p.x, p.y, rn.net.name, rn.net.class, true));
|
tieLabels.push(renderNetLabel(p.x, p.y, rn.net.name, rn.net.class, true));
|
||||||
}
|
}
|
||||||
@ -655,9 +553,11 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
const annotations = annotationEntries
|
const annotations = (model.annotations ?? [])
|
||||||
.map((a) => {
|
.map((a, idx) => {
|
||||||
return `<text x="${a.x}" y="${a.y}" font-size="11" fill="#6b7280">${esc(a.text)}</text>`;
|
const x = a.x ?? 16;
|
||||||
|
const y = a.y ?? 24 + idx * 16;
|
||||||
|
return `<text x="${x}" y="${y}" font-size="11" fill="#6b7280">${esc(a.text)}</text>`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
|
|||||||
@ -27,9 +27,6 @@ test("REST compile contract shape is stable with version metadata", () => {
|
|||||||
assert.ok(Array.isArray(body.warnings));
|
assert.ok(Array.isArray(body.warnings));
|
||||||
assert.ok(Array.isArray(body.bus_groups));
|
assert.ok(Array.isArray(body.bus_groups));
|
||||||
assert.equal(typeof body.render_mode_used, "string");
|
assert.equal(typeof body.render_mode_used, "string");
|
||||||
assert.equal(typeof body.layout_engine_requested, "string");
|
|
||||||
assert.equal(typeof body.layout_engine_used, "string");
|
|
||||||
assert.ok(Array.isArray(body.layout_warnings));
|
|
||||||
assert.equal(typeof body.svg, "string");
|
assert.equal(typeof body.svg, "string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 193 KiB |
@ -36,47 +36,6 @@ test("compile accepts render mode options", () => {
|
|||||||
assert.equal(result.render_mode_used, "explicit");
|
assert.equal(result.render_mode_used, "explicit");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("compile default layout engine path remains stable", () => {
|
|
||||||
const baseline = compile(fixture);
|
|
||||||
const explicitDefault = compile(fixture, { use_elk_layout: false });
|
|
||||||
|
|
||||||
assert.equal(baseline.ok, true);
|
|
||||||
assert.deepEqual(explicitDefault.layout, baseline.layout);
|
|
||||||
assert.deepEqual(explicitDefault.layout_metrics, baseline.layout_metrics);
|
|
||||||
assert.equal(baseline.layout_engine_requested, "schemeta-v2");
|
|
||||||
assert.equal(baseline.layout_engine_used, "schemeta-v2");
|
|
||||||
assert.deepEqual(baseline.layout_warnings, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compile accepts ELK layout flag option", () => {
|
|
||||||
const result = compile(fixture, {
|
|
||||||
use_elk_layout: true,
|
|
||||||
elk_runtime_module: "__missing_elk_runtime_for_test__"
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
|
||||||
assert.equal(result.layout_engine_requested, "elk");
|
|
||||||
assert.equal(result.layout_engine_used, "schemeta-v2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compile ELK fallback is deterministic when runtime is unavailable", () => {
|
|
||||||
const options = {
|
|
||||||
use_elk_layout: true,
|
|
||||||
elk_runtime_module: "__missing_elk_runtime_for_test__"
|
|
||||||
};
|
|
||||||
const runA = compile(fixture, options);
|
|
||||||
const runB = compile(fixture, options);
|
|
||||||
|
|
||||||
assert.equal(runA.ok, true);
|
|
||||||
assert.deepEqual(runB.layout, runA.layout);
|
|
||||||
assert.deepEqual(runB.layout_metrics, runA.layout_metrics);
|
|
||||||
assert.equal(runA.layout_engine_requested, "elk");
|
|
||||||
assert.equal(runA.layout_engine_used, "schemeta-v2");
|
|
||||||
assert.equal(runA.layout_warnings.length, 1);
|
|
||||||
assert.equal(runA.layout_warnings[0].code, "elk_layout_unavailable_fallback");
|
|
||||||
assert.deepEqual(runB.layout_warnings, runA.layout_warnings);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compile auto-creates generic symbols for unknown instances", () => {
|
test("compile auto-creates generic symbols for unknown instances", () => {
|
||||||
const model = {
|
const model = {
|
||||||
meta: { title: "Generic Demo" },
|
meta: { title: "Generic Demo" },
|
||||||
|
|||||||
@ -15,5 +15,5 @@ test("render output for reference fixture remains deterministic", () => {
|
|||||||
assert.equal(outA.ok, true);
|
assert.equal(outA.ok, true);
|
||||||
assert.equal(outB.ok, true);
|
assert.equal(outB.ok, true);
|
||||||
assert.equal(outA.svg, outB.svg);
|
assert.equal(outA.svg, outB.svg);
|
||||||
assert.equal(svgHash(outA.svg), "8dc4f0722829a68136cb237373a8d3e26669c693ec7f4287c92d22772488b99f");
|
assert.equal(svgHash(outA.svg), "8cdeb27f324decbd375fc9b127c7361f204c4e167551076178d6ad52dee66f94");
|
||||||
});
|
});
|
||||||
|
|||||||