Shape-based collision, hit-testing, and export for all symbol types
- Add induction collision vertices (arrow+strip polygon instead of full OBB) - Add getShapeVertices() dispatcher for unified shape-based collision - Shape-following hit tests: curved (arc band), induction (arrow+strip), spur (trapezoid) - Curved SVG export uses programmatic arc path matching renderer - EPC: right-click adds waypoint, free polyline (no angle snap) - Thinner selection/collision outlines, smaller resize handles with larger hit area Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
93afd0a554
commit
8c29d9266c
BIN
projectes/CDW5/pdf/CDW5_SYSDL_MCM09 Non Con PH1-SYSDL.pdf
Normal file
BIN
projectes/CDW5/pdf/CDW5_SYSDL_MCM09 Non Con PH1-SYSDL.pdf
Normal file
Binary file not shown.
@ -1,5 +1,5 @@
|
|||||||
import type { AABB, EpcWaypoint } from '../types.js';
|
import type { AABB, EpcWaypoint } from '../types.js';
|
||||||
import { SPACING_EXEMPT, isCurvedType, isSpurType, isEpcType, getCurveGeometry, EPC_CONFIG } from '../symbols.js';
|
import { SPACING_EXEMPT, isCurvedType, isSpurType, isEpcType, isInductionType, getCurveGeometry, EPC_CONFIG, INDUCTION_CONFIG } from '../symbols.js';
|
||||||
import { layout } from '../stores/layout.svelte.js';
|
import { layout } from '../stores/layout.svelte.js';
|
||||||
import { orientedBoxCorners, createCurveTransforms } from './geometry.js';
|
import { orientedBoxCorners, createCurveTransforms } from './geometry.js';
|
||||||
import { getAABB, distPointToSegment, distToAnnularSector, pointToOBBDist, pointInConvexPolygon, pointToConvexPolygonDist, edgeDistance } from './distance.js';
|
import { getAABB, distPointToSegment, distToAnnularSector, pointToOBBDist, pointInConvexPolygon, pointToConvexPolygonDist, edgeDistance } from './distance.js';
|
||||||
@ -63,6 +63,34 @@ function getSpurVertices(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the convex hull vertices of an induction shape (arrow + strip) in world space */
|
||||||
|
function getInductionVertices(
|
||||||
|
x: number, y: number, w: number, h: number, rotation: number
|
||||||
|
): [number, number][] {
|
||||||
|
const hw = INDUCTION_CONFIG.headWidth;
|
||||||
|
const stripTopY = y + h * INDUCTION_CONFIG.stripTopFrac;
|
||||||
|
const stripBottomY = y + h * INDUCTION_CONFIG.stripBottomFrac;
|
||||||
|
const arrowPts = INDUCTION_CONFIG.arrowPoints;
|
||||||
|
// Convex hull: top-right strip, arrow top, arrow left, arrow bottom, bottom-right strip
|
||||||
|
const local: [number, number][] = [
|
||||||
|
[x + w, stripTopY], // strip top-right
|
||||||
|
[x + arrowPts[0][0] * hw, y + arrowPts[0][1] * h], // arrow top junction
|
||||||
|
[x + arrowPts[1][0] * hw, y + arrowPts[1][1] * h], // arrow top
|
||||||
|
[x + arrowPts[2][0] * hw, y + arrowPts[2][1] * h], // arrow left point
|
||||||
|
[x + arrowPts[3][0] * hw, y + arrowPts[3][1] * h], // arrow bottom
|
||||||
|
[x + arrowPts[5][0] * hw, y + arrowPts[5][1] * h], // arrow bottom junction
|
||||||
|
[x + w, stripBottomY], // strip bottom-right
|
||||||
|
];
|
||||||
|
if (!rotation) return local;
|
||||||
|
const cx = x + w / 2, cy = y + h / 2;
|
||||||
|
const rad = rotation * Math.PI / 180;
|
||||||
|
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||||
|
return local.map(([px, py]) => {
|
||||||
|
const dx = px - cx, dy = py - cy;
|
||||||
|
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos] as [number, number];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the 4 vertices of an OBB in world space */
|
/** Get the 4 vertices of an OBB in world space */
|
||||||
function getOBBVertices(
|
function getOBBVertices(
|
||||||
x: number, y: number, w: number, h: number, rotation: number
|
x: number, y: number, w: number, h: number, rotation: number
|
||||||
@ -406,6 +434,16 @@ function anyQuadVsCurved(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get collision vertices for any non-curved, non-EPC symbol based on its actual shape */
|
||||||
|
function getShapeVertices(
|
||||||
|
x: number, y: number, w: number, h: number, rotation: number,
|
||||||
|
symbolId: string, w2?: number
|
||||||
|
): [number, number][] {
|
||||||
|
if (isSpurType(symbolId)) return getSpurVertices(x, y, w, h, w2 ?? w, rotation);
|
||||||
|
if (isInductionType(symbolId)) return getInductionVertices(x, y, w, h, rotation);
|
||||||
|
return getOBBVertices(x, y, w, h, rotation);
|
||||||
|
}
|
||||||
|
|
||||||
export function checkSpacingViolation(
|
export function checkSpacingViolation(
|
||||||
id: number, x: number, y: number, w: number, h: number, rotation: number,
|
id: number, x: number, y: number, w: number, h: number, rotation: number,
|
||||||
symbolId?: string, curveAngle?: number, w2?: number
|
symbolId?: string, curveAngle?: number, w2?: number
|
||||||
@ -416,6 +454,7 @@ export function checkSpacingViolation(
|
|||||||
const isCurved = !!(symbolId && isCurvedType(symbolId));
|
const isCurved = !!(symbolId && isCurvedType(symbolId));
|
||||||
const isSpur = !!(symbolId && isSpurType(symbolId));
|
const isSpur = !!(symbolId && isSpurType(symbolId));
|
||||||
const isEpc = !!(symbolId && isEpcType(symbolId));
|
const isEpc = !!(symbolId && isEpcType(symbolId));
|
||||||
|
const isInduction = !!(symbolId && isInductionType(symbolId));
|
||||||
|
|
||||||
// Get EPC quads for "this" symbol if it's an EPC
|
// Get EPC quads for "this" symbol if it's an EPC
|
||||||
let epcQuads: [number, number][][] | null = null;
|
let epcQuads: [number, number][][] | null = null;
|
||||||
@ -431,6 +470,7 @@ export function checkSpacingViolation(
|
|||||||
const symIsCurved = isCurvedType(sym.symbolId);
|
const symIsCurved = isCurvedType(sym.symbolId);
|
||||||
const symIsSpur = isSpurType(sym.symbolId);
|
const symIsSpur = isSpurType(sym.symbolId);
|
||||||
const symIsEpc = isEpcType(sym.symbolId);
|
const symIsEpc = isEpcType(sym.symbolId);
|
||||||
|
const symIsInduction = isInductionType(sym.symbolId);
|
||||||
|
|
||||||
// Get EPC quads for "other" symbol if it's EPC
|
// Get EPC quads for "other" symbol if it's EPC
|
||||||
let symEpcQuads: [number, number][][] | null = null;
|
let symEpcQuads: [number, number][][] | null = null;
|
||||||
@ -455,68 +495,32 @@ export function checkSpacingViolation(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── EPC vs Spur ──
|
// ── EPC vs non-curved/non-EPC ──
|
||||||
if (isEpc && symIsSpur && epcQuads) {
|
if (isEpc && epcQuads && !symIsCurved && !symIsEpc) {
|
||||||
const spurVerts = getSpurVertices(sym.x, sym.y, sym.w, sym.h, sym.w2 ?? sym.w, sym.rotation);
|
const bVerts = getShapeVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.w2);
|
||||||
if (anyQuadVsPolygon(epcQuads, spurVerts, spacing)) return true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isSpur && symIsEpc && symEpcQuads) {
|
|
||||||
const spurVerts = getSpurVertices(x, y, w, h, w2 ?? w, rotation);
|
|
||||||
if (anyQuadVsPolygon(symEpcQuads, spurVerts, spacing)) return true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── EPC vs OBB (regular symbol) ──
|
|
||||||
if (isEpc && epcQuads && !symIsCurved && !symIsSpur && !symIsEpc) {
|
|
||||||
const bVerts = getOBBVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation);
|
|
||||||
if (anyQuadVsPolygon(epcQuads, bVerts, spacing)) return true;
|
if (anyQuadVsPolygon(epcQuads, bVerts, spacing)) return true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (symIsEpc && symEpcQuads && !isCurved && !isSpur && !isEpc) {
|
if (symIsEpc && symEpcQuads && !isCurved && !isEpc) {
|
||||||
const aVerts = getOBBVertices(x, y, w, h, rotation);
|
const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, w2);
|
||||||
if (anyQuadVsPolygon(symEpcQuads, aVerts, spacing)) return true;
|
if (anyQuadVsPolygon(symEpcQuads, aVerts, spacing)) return true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Non-EPC logic (existing) ──
|
// ── Non-EPC, non-curved: shape polygon vs shape polygon ──
|
||||||
if (!isCurved && !symIsCurved) {
|
if (!isCurved && !symIsCurved) {
|
||||||
if (isSpur || symIsSpur) {
|
const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, w2);
|
||||||
const aVerts = isSpur
|
const bVerts = getShapeVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.w2);
|
||||||
? getSpurVertices(x, y, w, h, w2 ?? w, rotation)
|
if (polygonSATWithSpacing(aVerts, bVerts, spacing)) return true;
|
||||||
: getOBBVertices(x, y, w, h, rotation);
|
|
||||||
const bVerts = symIsSpur
|
|
||||||
? getSpurVertices(sym.x, sym.y, sym.w, sym.h, sym.w2 ?? sym.w, sym.rotation)
|
|
||||||
: getOBBVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation);
|
|
||||||
if (polygonSATWithSpacing(aVerts, bVerts, spacing)) return true;
|
|
||||||
} else {
|
|
||||||
if (obbOverlapWithSpacing(x, y, w, h, rotation, sym.x, sym.y, sym.w, sym.h, sym.rotation, spacing)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (isCurved && !symIsCurved) {
|
} else if (isCurved && !symIsCurved) {
|
||||||
if (symIsSpur) {
|
const bVerts = getShapeVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.w2);
|
||||||
const spurVerts = getSpurVertices(sym.x, sym.y, sym.w, sym.h, sym.w2 ?? sym.w, sym.rotation);
|
if (checkCurvedVsSpur(
|
||||||
if (checkCurvedVsSpur(
|
{ x, y, w, h, rotation, symbolId: symbolId!, curveAngle },
|
||||||
{ x, y, w, h, rotation, symbolId: symbolId!, curveAngle },
|
bVerts, spacing
|
||||||
spurVerts, spacing
|
)) return true;
|
||||||
)) return true;
|
|
||||||
} else {
|
|
||||||
if (checkCurvedVsOBB(
|
|
||||||
{ x, y, w, h, rotation, symbolId: symbolId!, curveAngle },
|
|
||||||
sym.x, sym.y, sym.w, sym.h, sym.rotation, spacing
|
|
||||||
)) return true;
|
|
||||||
}
|
|
||||||
} else if (!isCurved && symIsCurved) {
|
} else if (!isCurved && symIsCurved) {
|
||||||
if (isSpur) {
|
const aVerts = getShapeVertices(x, y, w, h, rotation, symbolId!, w2);
|
||||||
const spurVerts = getSpurVertices(x, y, w, h, w2 ?? w, rotation);
|
if (checkCurvedVsSpur(sym, aVerts, spacing)) return true;
|
||||||
if (checkCurvedVsSpur(sym, spurVerts, spacing)) return true;
|
|
||||||
} else {
|
|
||||||
if (checkCurvedVsOBB(
|
|
||||||
sym,
|
|
||||||
x, y, w, h, rotation, spacing
|
|
||||||
)) return true;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Both curved: use band AABBs as fallback
|
// Both curved: use band AABBs as fallback
|
||||||
const aBoxes = getCurvedBandAABBs({ x, y, w, h, rotation, symbolId: symbolId!, curveAngle });
|
const aBoxes = getCurvedBandAABBs({ x, y, w, h, rotation, symbolId: symbolId!, curveAngle });
|
||||||
|
|||||||
@ -120,6 +120,39 @@ export function hitResizeHandle(cx: number, cy: number, sym: PlacedSymbol): 'lef
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check if a point is inside an arc band (annular sector) */
|
||||||
|
function pointInArcBand(px: number, py: number, sym: PlacedSymbol): boolean {
|
||||||
|
const angle = sym.curveAngle || 90;
|
||||||
|
const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
|
||||||
|
const dx = px - arcCx, dy = -(py - arcCy); // flip Y to math convention (Y up)
|
||||||
|
const r = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (r < innerR || r > outerR) return false;
|
||||||
|
let theta = Math.atan2(dy, dx);
|
||||||
|
if (theta < -0.01) theta += 2 * Math.PI;
|
||||||
|
const sweepRad = (angle * Math.PI) / 180;
|
||||||
|
return theta >= -0.01 && theta <= sweepRad + 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a point is inside the induction shape (arrow head + strip) */
|
||||||
|
function pointInInduction(px: number, py: number, sym: PlacedSymbol): boolean {
|
||||||
|
const hw = INDUCTION_CONFIG.headWidth;
|
||||||
|
const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
|
||||||
|
const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
|
||||||
|
// Check strip rect
|
||||||
|
if (px >= sym.x + hw * 0.55 && px <= sym.x + sym.w && py >= stripTopY && py <= stripBottomY) return true;
|
||||||
|
// Check arrow head (approximate as bounding rect of arrow points)
|
||||||
|
if (px >= sym.x && px <= sym.x + hw && py >= sym.y && py <= sym.y + sym.h) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hit test a single symbol against its actual shape */
|
||||||
|
function pointInSymbol(px: number, py: number, sym: PlacedSymbol): boolean {
|
||||||
|
if (isCurvedType(sym.symbolId)) return pointInArcBand(px, py, sym);
|
||||||
|
if (isSpurType(sym.symbolId)) return pointInTrapezoid(px, py, sym);
|
||||||
|
if (isInductionType(sym.symbolId)) return pointInInduction(px, py, sym);
|
||||||
|
return pointInRect(px, py, sym.x, sym.y, sym.w, sym.h);
|
||||||
|
}
|
||||||
|
|
||||||
/** Hit test all symbols, returning the id of the topmost hit, or null */
|
/** Hit test all symbols, returning the id of the topmost hit, or null */
|
||||||
export function hitTestSymbols(
|
export function hitTestSymbols(
|
||||||
cx: number, cy: number,
|
cx: number, cy: number,
|
||||||
@ -128,11 +161,7 @@ export function hitTestSymbols(
|
|||||||
for (let i = symbols.length - 1; i >= 0; i--) {
|
for (let i = symbols.length - 1; i >= 0; i--) {
|
||||||
const sym = symbols[i];
|
const sym = symbols[i];
|
||||||
const local = toSymbolLocal(cx, cy, sym);
|
const local = toSymbolLocal(cx, cy, sym);
|
||||||
if (isSpurType(sym.symbolId)) {
|
if (pointInSymbol(local.x, local.y, sym)) return sym.id;
|
||||||
if (pointInTrapezoid(local.x, local.y, sym)) return sym.id;
|
|
||||||
} else if (pointInRect(local.x, local.y, sym.x, sym.y, sym.w, sym.h)) {
|
|
||||||
return sym.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,19 +3,45 @@ import { isEpcType, isInductionType, isSpurType, isCurvedType, EPC_CONFIG, INDUC
|
|||||||
import { deserializeSymbol } from './serialization.js';
|
import { deserializeSymbol } from './serialization.js';
|
||||||
import type { PlacedSymbol } from './types.js';
|
import type { PlacedSymbol } from './types.js';
|
||||||
|
|
||||||
/** If the SVG root has a single <g> child (with only a transform attr), unwrap it. */
|
/** Recursively collect all leaf (non-<g>) elements from an SVG element tree,
|
||||||
function unwrapSingleGroup(svgEl: Element): { content: string; extraTransform: string } {
|
* composing transforms as we descend through <g> nodes. */
|
||||||
const children = Array.from(svgEl.children);
|
function collectLeaves(el: Element, parentTransform: string): { el: Element; transform: string }[] {
|
||||||
if (children.length === 1 && children[0].tagName === 'g') {
|
const results: { el: Element; transform: string }[] = [];
|
||||||
const innerG = children[0];
|
for (const child of Array.from(el.children)) {
|
||||||
if (Array.from(innerG.attributes).every(a => a.name === 'transform')) {
|
const childTransform = child.getAttribute('transform') || '';
|
||||||
return {
|
const composed = [parentTransform, childTransform].filter(Boolean).join(' ');
|
||||||
content: innerG.innerHTML,
|
if (child.tagName === 'g') {
|
||||||
extraTransform: innerG.getAttribute('transform') || ''
|
// Recurse into groups, composing their transform
|
||||||
};
|
results.push(...collectLeaves(child, composed));
|
||||||
|
} else {
|
||||||
|
results.push({ el: child, transform: composed });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { content: svgEl.innerHTML, extraTransform: '' };
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize an element to string, optionally overriding its transform,
|
||||||
|
* and stripping the default xmlns that XMLSerializer adds. */
|
||||||
|
function serializeLeaf(el: Element, transform: string, id?: string, inkscapeLabel?: string): string {
|
||||||
|
const clone = el.cloneNode(true) as Element;
|
||||||
|
if (transform) {
|
||||||
|
clone.setAttribute('transform', transform);
|
||||||
|
} else {
|
||||||
|
clone.removeAttribute('transform');
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
clone.setAttribute('id', id);
|
||||||
|
}
|
||||||
|
// inkscape:label via setAttributeNS won't serialize correctly with XMLSerializer,
|
||||||
|
// so we inject it as a string afterwards
|
||||||
|
let s = new XMLSerializer().serializeToString(clone)
|
||||||
|
.replace(/ xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, '');
|
||||||
|
if (inkscapeLabel) {
|
||||||
|
// Inject inkscape:label right after the tag name
|
||||||
|
const firstSpace = s.indexOf(' ');
|
||||||
|
s = s.slice(0, firstSpace) + ` inkscape:label="${inkscapeLabel}"` + s.slice(firstSpace);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadBlob(blob: Blob, filename: string) {
|
function downloadBlob(blob: Blob, filename: string) {
|
||||||
@ -27,57 +53,6 @@ function downloadBlob(blob: Blob, filename: string) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildEpcSvgElements(sym: PlacedSymbol): Promise<string> {
|
|
||||||
const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints;
|
|
||||||
const ox = sym.x;
|
|
||||||
const oy = sym.y;
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
// Polyline
|
|
||||||
if (waypoints.length >= 2) {
|
|
||||||
const points = waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' ');
|
|
||||||
parts.push(` <polyline points="${points}" fill="none" stroke="#000000" stroke-width="${EPC_CONFIG.lineWidth}" />`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (waypoints.length >= 2) {
|
|
||||||
// Left icon — embed actual SVG, oriented along first segment
|
|
||||||
const lb = EPC_CONFIG.leftBox;
|
|
||||||
const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y;
|
|
||||||
const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y;
|
|
||||||
const lAngle = Math.atan2(p1y - p0y, p1x - p0x) * 180 / Math.PI;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const svgText = await (await fetch(EPC_CONFIG.iconFile)).text();
|
|
||||||
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
|
|
||||||
const svgEl = doc.documentElement;
|
|
||||||
const vb = svgEl.getAttribute('viewBox');
|
|
||||||
const [vbX, vbY, vbW, vbH] = vb ? vb.split(/[\s,]+/).map(Number) : [0, 0, lb.w, lb.h];
|
|
||||||
const sx = lb.w / vbW;
|
|
||||||
const sy = lb.h / vbH;
|
|
||||||
const { content, extraTransform } = unwrapSingleGroup(svgEl);
|
|
||||||
let transform = `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)}) translate(${-lb.w},${-lb.h / 2}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
|
|
||||||
if (extraTransform) transform += ` ${extraTransform}`;
|
|
||||||
parts.push(` <g transform="${transform}">`);
|
|
||||||
parts.push(` ${content}`);
|
|
||||||
parts.push(` </g>`);
|
|
||||||
} catch {
|
|
||||||
// Fallback: plain rect
|
|
||||||
parts.push(` <rect x="${-lb.w}" y="${-lb.h / 2}" width="${lb.w}" height="${lb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)})" />`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right box — oriented along last segment, left-center at wp[last]
|
|
||||||
const last = waypoints[waypoints.length - 1];
|
|
||||||
const prev = waypoints[waypoints.length - 2];
|
|
||||||
const plx = ox + last.x, ply = oy + last.y;
|
|
||||||
const ppx = ox + prev.x, ppy = oy + prev.y;
|
|
||||||
const rAngle = Math.atan2(ply - ppy, plx - ppx) * 180 / Math.PI;
|
|
||||||
const rb = EPC_CONFIG.rightBox;
|
|
||||||
parts.push(` <rect x="0" y="${-rb.h / 2}" width="${rb.w}" height="${rb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})" />`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportSVG() {
|
export async function exportSVG() {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
|
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
|
||||||
@ -92,31 +67,29 @@ export async function exportSVG() {
|
|||||||
const mirrored = sym.mirrored || false;
|
const mirrored = sym.mirrored || false;
|
||||||
const cx = sym.x + sym.w / 2;
|
const cx = sym.x + sym.w / 2;
|
||||||
const cy = sym.y + sym.h / 2;
|
const cy = sym.y + sym.h / 2;
|
||||||
let outerTransformParts: string[] = [];
|
|
||||||
if (rot) outerTransformParts.push(`rotate(${rot},${cx},${cy})`);
|
|
||||||
if (mirrored) outerTransformParts.push(`translate(${cx},0) scale(-1,1) translate(${-cx},0)`);
|
|
||||||
const rotAttr = outerTransformParts.length ? ` transform="${outerTransformParts.join(' ')}"` : '';
|
|
||||||
const label = sym.label || sym.name;
|
const label = sym.label || sym.name;
|
||||||
const idAttr = ` id="${label}" inkscape:label="${label}"`;
|
|
||||||
|
// Build outer transform (rotation + mirror)
|
||||||
|
const outerParts: string[] = [];
|
||||||
|
if (rot) outerParts.push(`rotate(${rot},${cx},${cy})`);
|
||||||
|
if (mirrored) outerParts.push(`translate(${cx},0) scale(-1,1) translate(${-cx},0)`);
|
||||||
|
const outerTransform = outerParts.join(' ');
|
||||||
|
|
||||||
if (isEpcType(sym.symbolId)) {
|
if (isEpcType(sym.symbolId)) {
|
||||||
lines.push(` <g${idAttr}${rotAttr}>`);
|
// EPC: polyline + icon + right box — emit flat elements
|
||||||
lines.push(await buildEpcSvgElements(sym as PlacedSymbol));
|
await emitEpcFlat(lines, sym as PlacedSymbol, label, outerTransform);
|
||||||
lines.push(' </g>');
|
|
||||||
} else if (isInductionType(sym.symbolId)) {
|
} else if (isInductionType(sym.symbolId)) {
|
||||||
const hw = INDUCTION_CONFIG.headWidth;
|
const hw = INDUCTION_CONFIG.headWidth;
|
||||||
const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
|
const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac;
|
||||||
const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
|
const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac;
|
||||||
const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const);
|
const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const);
|
||||||
const d = `M ${sym.x + sym.w},${stripTopY} L ${pts[0][0]},${stripTopY} ${pts.map(([px, py]) => `L ${px},${py}`).join(' ')} L ${pts[5][0]},${stripBottomY} L ${sym.x + sym.w},${stripBottomY} Z`;
|
const d = `M ${sym.x + sym.w},${stripTopY} L ${pts[0][0]},${stripTopY} ${pts.map(([px, py]) => `L ${px},${py}`).join(' ')} L ${pts[5][0]},${stripBottomY} L ${sym.x + sym.w},${stripBottomY} Z`;
|
||||||
lines.push(` <g${idAttr}>`);
|
const t = outerTransform || undefined;
|
||||||
lines.push(` <path d="${d}" fill="#000000"${rotAttr} />`);
|
lines.push(` <path id="${label}" inkscape:label="${label}" d="${d}" fill="#000000"${t ? ` transform="${t}"` : ''} />`);
|
||||||
lines.push(' </g>');
|
|
||||||
} else if (isCurvedType(sym.symbolId)) {
|
} else if (isCurvedType(sym.symbolId)) {
|
||||||
const angle = sym.curveAngle || 90;
|
const angle = sym.curveAngle || 90;
|
||||||
const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
|
const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
|
||||||
const sweepRad = (angle * Math.PI) / 180;
|
const sweepRad = (angle * Math.PI) / 180;
|
||||||
// Outer arc: from angle=0 to angle=-sweep (CCW in SVG = large-arc with sweep-flag 0)
|
|
||||||
const outerEndX = arcCx + outerR * Math.cos(sweepRad);
|
const outerEndX = arcCx + outerR * Math.cos(sweepRad);
|
||||||
const outerEndY = arcCy - outerR * Math.sin(sweepRad);
|
const outerEndY = arcCy - outerR * Math.sin(sweepRad);
|
||||||
const innerEndX = arcCx + innerR * Math.cos(sweepRad);
|
const innerEndX = arcCx + innerR * Math.cos(sweepRad);
|
||||||
@ -129,21 +102,19 @@ export async function exportSVG() {
|
|||||||
`A ${innerR},${innerR} 0 ${largeArc},1 ${arcCx + innerR},${arcCy}`,
|
`A ${innerR},${innerR} 0 ${largeArc},1 ${arcCx + innerR},${arcCy}`,
|
||||||
'Z',
|
'Z',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
lines.push(` <g${idAttr}${rotAttr}>`);
|
const t = outerTransform || undefined;
|
||||||
lines.push(` <path d="${d}" fill="#000000" stroke="#000000" stroke-width="0.5" />`);
|
lines.push(` <path id="${label}" inkscape:label="${label}" d="${d}" fill="#000000" stroke="#000000" stroke-width="0.5"${t ? ` transform="${t}"` : ''} />`);
|
||||||
lines.push(' </g>');
|
|
||||||
} else if (isSpurType(sym.symbolId)) {
|
} else if (isSpurType(sym.symbolId)) {
|
||||||
const w2 = sym.w2 ?? sym.w;
|
const w2 = sym.w2 ?? sym.w;
|
||||||
const points = `${sym.x},${sym.y} ${sym.x + w2},${sym.y} ${sym.x + sym.w},${sym.y + sym.h} ${sym.x},${sym.y + sym.h}`;
|
const d = `M ${sym.x},${sym.y} L ${sym.x + w2},${sym.y} L ${sym.x + sym.w},${sym.y + sym.h} L ${sym.x},${sym.y + sym.h} Z`;
|
||||||
lines.push(` <g${idAttr}${rotAttr}>`);
|
const t = outerTransform || undefined;
|
||||||
lines.push(` <polygon points="${points}" fill="#000000" stroke="#000000" stroke-width="0.5" />`);
|
lines.push(` <path id="${label}" inkscape:label="${label}" d="${d}" fill="#000000" stroke="#000000" stroke-width="0.5"${t ? ` transform="${t}"` : ''} />`);
|
||||||
lines.push(' </g>');
|
|
||||||
} else {
|
} else {
|
||||||
|
// Regular SVG symbol — flatten all children
|
||||||
try {
|
try {
|
||||||
const svgText = await (await fetch(sym.file)).text();
|
const svgText = await (await fetch(sym.file)).text();
|
||||||
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
|
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
|
||||||
const svgEl = doc.documentElement;
|
const svgEl = doc.documentElement;
|
||||||
// Skip if DOMParser returned an error (e.g., 404 or invalid SVG)
|
|
||||||
if (svgEl.querySelector('parsererror')) {
|
if (svgEl.querySelector('parsererror')) {
|
||||||
console.error('SVG parse error for:', sym.file);
|
console.error('SVG parse error for:', sym.file);
|
||||||
continue;
|
continue;
|
||||||
@ -155,19 +126,26 @@ export async function exportSVG() {
|
|||||||
const sx = sym.w / vbW;
|
const sx = sym.w / vbW;
|
||||||
const sy = sym.h / vbH;
|
const sy = sym.h / vbH;
|
||||||
|
|
||||||
const { content, extraTransform } = unwrapSingleGroup(svgEl);
|
// Base transform: position + scale from viewBox
|
||||||
let transform = `translate(${sym.x},${sym.y}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
|
let baseTransform = `translate(${sym.x},${sym.y}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
|
||||||
if (extraTransform) transform += ` ${extraTransform}`;
|
|
||||||
if (mirrored) {
|
if (mirrored) {
|
||||||
transform = `translate(${cx},0) scale(-1,1) translate(${-cx},0) ${transform}`;
|
baseTransform = `translate(${cx},0) scale(-1,1) translate(${-cx},0) ${baseTransform}`;
|
||||||
}
|
}
|
||||||
if (rot) {
|
if (rot) {
|
||||||
transform = `rotate(${rot},${cx},${cy}) ${transform}`;
|
baseTransform = `rotate(${rot},${cx},${cy}) ${baseTransform}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(` <g${idAttr} transform="${transform}">`);
|
// Collect all leaf elements with composed transforms
|
||||||
lines.push(` ${content}`);
|
const leaves = collectLeaves(svgEl, baseTransform);
|
||||||
lines.push(' </g>');
|
|
||||||
|
for (let i = 0; i < leaves.length; i++) {
|
||||||
|
const leaf = leaves[i];
|
||||||
|
if (i === 0) {
|
||||||
|
lines.push(` ${serializeLeaf(leaf.el, leaf.transform, label, label)}`);
|
||||||
|
} else {
|
||||||
|
lines.push(` ${serializeLeaf(leaf.el, leaf.transform)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to embed symbol:', sym.name, err);
|
console.error('Failed to embed symbol:', sym.name, err);
|
||||||
}
|
}
|
||||||
@ -178,6 +156,74 @@ export async function exportSVG() {
|
|||||||
downloadBlob(new Blob([lines.join('\n')], { type: 'image/svg+xml' }), 'test_view.svg');
|
downloadBlob(new Blob([lines.join('\n')], { type: 'image/svg+xml' }), 'test_view.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emit EPC symbol elements flat (no group wrapper) */
|
||||||
|
async function emitEpcFlat(lines: string[], sym: PlacedSymbol, label: string, outerTransform: string) {
|
||||||
|
const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints;
|
||||||
|
const ox = sym.x;
|
||||||
|
const oy = sym.y;
|
||||||
|
let isFirst = true;
|
||||||
|
|
||||||
|
const idAttrs = (addId: boolean) => {
|
||||||
|
if (!addId) return '';
|
||||||
|
return ` id="${label}" inkscape:label="${label}"`;
|
||||||
|
};
|
||||||
|
const tAttr = (t: string) => t ? ` transform="${t}"` : '';
|
||||||
|
|
||||||
|
// Polyline as path
|
||||||
|
if (waypoints.length >= 2) {
|
||||||
|
const d = 'M ' + waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' L ');
|
||||||
|
lines.push(` <path${idAttrs(isFirst)} d="${d}" fill="none" stroke="#000000" stroke-width="${EPC_CONFIG.lineWidth}"${tAttr(outerTransform)} />`);
|
||||||
|
isFirst = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waypoints.length >= 2) {
|
||||||
|
// Left icon
|
||||||
|
const lb = EPC_CONFIG.leftBox;
|
||||||
|
const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y;
|
||||||
|
const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y;
|
||||||
|
const lAngle = Math.atan2(p1y - p0y, p1x - p0x) * 180 / Math.PI;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svgText = await (await fetch(EPC_CONFIG.iconFile)).text();
|
||||||
|
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
|
||||||
|
const svgEl = doc.documentElement;
|
||||||
|
const vb = svgEl.getAttribute('viewBox');
|
||||||
|
const [vbX, vbY, vbW, vbH] = vb ? vb.split(/[\s,]+/).map(Number) : [0, 0, lb.w, lb.h];
|
||||||
|
const sx = lb.w / vbW;
|
||||||
|
const sy = lb.h / vbH;
|
||||||
|
|
||||||
|
let iconBase = `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)}) translate(${-lb.w},${-lb.h / 2}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
|
||||||
|
if (outerTransform) iconBase = `${outerTransform} ${iconBase}`;
|
||||||
|
|
||||||
|
const leaves = collectLeaves(svgEl, iconBase);
|
||||||
|
for (const leaf of leaves) {
|
||||||
|
lines.push(` ${serializeLeaf(leaf.el, leaf.transform, isFirst ? label : undefined, isFirst ? label : undefined)}`);
|
||||||
|
isFirst = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const t = outerTransform
|
||||||
|
? `${outerTransform} translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)})`
|
||||||
|
: `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)})`;
|
||||||
|
const d = `M ${-lb.w},${-lb.h / 2} L 0,${-lb.h / 2} L 0,${lb.h / 2} L ${-lb.w},${lb.h / 2} Z`;
|
||||||
|
lines.push(` <path${idAttrs(isFirst)} d="${d}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="${t}" />`);
|
||||||
|
isFirst = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right box
|
||||||
|
const last = waypoints[waypoints.length - 1];
|
||||||
|
const prev = waypoints[waypoints.length - 2];
|
||||||
|
const plx = ox + last.x, ply = oy + last.y;
|
||||||
|
const ppx = ox + prev.x, ppy = oy + prev.y;
|
||||||
|
const rAngle = Math.atan2(ply - ppy, plx - ppx) * 180 / Math.PI;
|
||||||
|
const rb = EPC_CONFIG.rightBox;
|
||||||
|
const rTransform = outerTransform
|
||||||
|
? `${outerTransform} translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})`
|
||||||
|
: `translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})`;
|
||||||
|
const d = `M 0,${-rb.h / 2} L ${rb.w},${-rb.h / 2} L ${rb.w},${rb.h / 2} L 0,${rb.h / 2} Z`;
|
||||||
|
lines.push(` <path${idAttrs(isFirst)} d="${d}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="${rTransform}" />`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function loadLayoutJSON(file: File): Promise<void> {
|
export function loadLayoutJSON(file: File): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|||||||
@ -82,9 +82,10 @@ export const PRIORITY_TYPES = new Set([
|
|||||||
'photoeye', 'photoeye_v',
|
'photoeye', 'photoeye_v',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Photoeyes are exempt from spacing — can be placed freely on top of anything
|
// Overlay types: exempt from spacing — can be placed freely on top of anything
|
||||||
export const SPACING_EXEMPT = new Set([
|
export const SPACING_EXEMPT = new Set([
|
||||||
'photoeye', 'photoeye_v',
|
'photoeye', 'photoeye_v',
|
||||||
|
'fio_sio_fioh', 'fio_sio_fioh_v',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user