diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..208bf2c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+* text=auto eol=lf
+*.svg binary
+*.xlsx binary
+*.pdf binary
+*.png binary
+*.jpg binary
diff --git a/svelte-app/src/components/Canvas.svelte b/svelte-app/src/components/Canvas.svelte
index 5f7e8ef..15e9a54 100644
--- a/svelte-app/src/components/Canvas.svelte
+++ b/svelte-app/src/components/Canvas.svelte
@@ -74,6 +74,16 @@
contextMenu = null;
}
+ function handleMirror() {
+ if (!contextMenu) return;
+ if (layout.selectedIds.has(contextMenu.symId) && layout.selectedIds.size > 1) {
+ layout.mirrorSelected();
+ } else {
+ layout.mirrorSymbol(contextMenu.symId);
+ }
+ contextMenu = null;
+ }
+
function handleSetLabel() {
if (!contextMenu) return;
const sym = layout.symbols.find(s => s.id === contextMenu!.symId);
@@ -130,6 +140,7 @@
role="menu"
>
+
diff --git a/svelte-app/src/lib/canvas/collision.ts b/svelte-app/src/lib/canvas/collision.ts
index 16a334d..e7d2ffd 100644
--- a/svelte-app/src/lib/canvas/collision.ts
+++ b/svelte-app/src/lib/canvas/collision.ts
@@ -1,19 +1,12 @@
import type { AABB, EpcWaypoint } from '../types.js';
-import { SPACING_EXEMPT, isCurvedType, isSpurType, isEpcType, getCurveBandWidth, EPC_CONFIG } from '../symbols.js';
+import { SPACING_EXEMPT, isCurvedType, isSpurType, isEpcType, getCurveGeometry, EPC_CONFIG } from '../symbols.js';
import { layout } from '../stores/layout.svelte.js';
import { orientedBoxCorners, createCurveTransforms } from './geometry.js';
+import { getAABB, distPointToSegment, distToAnnularSector, pointToOBBDist, pointInConvexPolygon, pointToConvexPolygonDist, edgeDistance } from './distance.js';
-export function getAABB(x: number, y: number, w: number, h: number, rotation: number): AABB {
- if (!rotation) return { x, y, w, h };
- const cx = x + w / 2;
- const cy = y + h / 2;
- const rad = (rotation * Math.PI) / 180;
- const cos = Math.abs(Math.cos(rad));
- const sin = Math.abs(Math.sin(rad));
- const bw = w * cos + h * sin;
- const bh = w * sin + h * cos;
- return { x: cx - bw / 2, y: cy - bh / 2, w: bw, h: bh };
-}
+// Re-export for consumers that import from collision.ts
+export { getAABB, clamp } from './distance.js';
+export { snapToGrid, findValidPosition } from './grid-snap.js';
// ─── OBB collision via Separating Axis Theorem ───
@@ -124,99 +117,6 @@ function polygonSATWithSpacing(
}
/** Check if point is inside a convex polygon (CCW or CW winding) */
-function pointInConvexPolygon(px: number, py: number, verts: [number, number][]): boolean {
- let sign = 0;
- for (let i = 0; i < verts.length; i++) {
- const [x1, y1] = verts[i];
- const [x2, y2] = verts[(i + 1) % verts.length];
- const cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1);
- if (cross !== 0) {
- if (sign === 0) sign = cross > 0 ? 1 : -1;
- else if ((cross > 0 ? 1 : -1) !== sign) return false;
- }
- }
- return true;
-}
-
-/** Distance from a point to a convex polygon (0 if inside) */
-function pointToConvexPolygonDist(px: number, py: number, verts: [number, number][]): number {
- if (pointInConvexPolygon(px, py, verts)) return 0;
- let minDist = Infinity;
- for (let i = 0; i < verts.length; i++) {
- const [x1, y1] = verts[i];
- const [x2, y2] = verts[(i + 1) % verts.length];
- minDist = Math.min(minDist, distPointToSegment(px, py, x1, y1, x2, y2));
- }
- return minDist;
-}
-
-// ─── Geometric helpers for annular sector distance ───
-
-/** Distance from a point to a line segment */
-function distPointToSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
- const dx = x2 - x1, dy = y2 - y1;
- const len2 = dx * dx + dy * dy;
- if (len2 === 0) return Math.sqrt((px - x1) ** 2 + (py - y1) ** 2);
- const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
- const nearX = x1 + t * dx, nearY = y1 + t * dy;
- return Math.sqrt((px - nearX) ** 2 + (py - nearY) ** 2);
-}
-
-/**
- * Distance from a point to an annular sector (arc band).
- * Point is in local space: arc center at origin, Y points UP,
- * sector sweeps CCW from angle 0 to sweepRad.
- */
-function distToAnnularSector(px: number, py: number, innerR: number, outerR: number, sweepRad: number): number {
- const r = Math.sqrt(px * px + py * py);
- let theta = Math.atan2(py, px);
- if (theta < -0.001) theta += 2 * Math.PI;
-
- const inAngle = theta >= -0.001 && theta <= sweepRad + 0.001;
-
- if (inAngle) {
- if (r >= innerR && r <= outerR) return 0; // inside sector
- if (r < innerR) return innerR - r;
- return r - outerR;
- }
-
- // Outside angular range — check distance to radial edges and corner points
- let minDist = Infinity;
-
- // Start radial edge (angle 0, from innerR to outerR along +X)
- minDist = Math.min(minDist, distPointToSegment(px, py, innerR, 0, outerR, 0));
-
- // End radial edge (angle sweepRad)
- const ec = Math.cos(sweepRad), es = Math.sin(sweepRad);
- minDist = Math.min(minDist, distPointToSegment(px, py, innerR * ec, innerR * es, outerR * ec, outerR * es));
-
- // Also check distance to the arcs at the clamped angle
- const clampedTheta = Math.max(0, Math.min(sweepRad, theta < 0 ? theta + 2 * Math.PI : theta));
- if (clampedTheta >= 0 && clampedTheta <= sweepRad) {
- const oc = Math.cos(clampedTheta), os = Math.sin(clampedTheta);
- minDist = Math.min(minDist, Math.sqrt((px - outerR * oc) ** 2 + (py - outerR * os) ** 2));
- if (innerR > 0) {
- minDist = Math.min(minDist, Math.sqrt((px - innerR * oc) ** 2 + (py - innerR * os) ** 2));
- }
- }
-
- return minDist;
-}
-
-/** Distance from a point in world space to an OBB */
-function pointToOBBDist(px: number, py: number, bx: number, by: number, bw: number, bh: number, brot: number): number {
- const bcx = bx + bw / 2, bcy = by + bh / 2;
- const rad = -(brot || 0) * Math.PI / 180;
- const cos = Math.cos(rad), sin = Math.sin(rad);
- const dx = px - bcx, dy = py - bcy;
- const lx = dx * cos - dy * sin;
- const ly = dx * sin + dy * cos;
- const hw = bw / 2, hh = bh / 2;
- const cx = Math.max(-hw, Math.min(hw, lx));
- const cy = Math.max(-hh, Math.min(hh, ly));
- return Math.sqrt((lx - cx) ** 2 + (ly - cy) ** 2);
-}
-
// ─── Curved vs OBB: true geometric check ───
interface CurveParams {
@@ -234,23 +134,15 @@ function checkCurvedVsOBB(
spacing: number
): boolean {
const angle = curve.curveAngle || 90;
- const outerR = curve.w;
- const bandW = getCurveBandWidth(curve.symbolId);
- const innerR = Math.max(0, outerR - bandW);
+ const { arcCx: acx, arcCy: acy, outerR, innerR } = getCurveGeometry(curve.symbolId, curve.x, curve.y, curve.w, curve.h);
const curveRot = (curve.rotation || 0) * Math.PI / 180;
const sweepRad = (angle * Math.PI) / 180;
- // Arc center in unrotated local space (bottom-left of bbox)
- const acx = curve.x;
- const acy = curve.y + curve.h;
-
- // Symbol center (rotation pivot)
const scx = curve.x + curve.w / 2;
const scy = curve.y + curve.h / 2;
const { toLocal, toWorld } = createCurveTransforms(acx, acy, scx, scy, curveRot);
- // Get OBB corners in world space
const otherCx = bx + bw / 2, otherCy = by + bh / 2;
const oRad = (brot || 0) * Math.PI / 180;
const oCos = Math.cos(oRad), oSin = Math.sin(oRad);
@@ -305,14 +197,10 @@ function checkCurvedVsSpur(
spacing: number
): boolean {
const angle = curve.curveAngle || 90;
- const outerR = curve.w;
- const bandW = getCurveBandWidth(curve.symbolId);
- const innerR = Math.max(0, outerR - bandW);
+ const { arcCx: acx, arcCy: acy, outerR, innerR } = getCurveGeometry(curve.symbolId, curve.x, curve.y, curve.w, curve.h);
const curveRot = (curve.rotation || 0) * Math.PI / 180;
const sweepRad = (angle * Math.PI) / 180;
- const acx = curve.x;
- const acy = curve.y + curve.h;
const scx = curve.x + curve.w / 2;
const scy = curve.y + curve.h / 2;
@@ -350,28 +238,13 @@ function checkCurvedVsSpur(
return false;
}
-// ─── Legacy AABB distance (for curved vs curved fallback) ───
-
-function edgeDistance(
- ax: number, ay: number, aw: number, ah: number,
- bx: number, by: number, bw: number, bh: number
-): number {
- const dx = Math.max(0, ax - (bx + bw), bx - (ax + aw));
- const dy = Math.max(0, ay - (by + bh), by - (ay + ah));
- return Math.sqrt(dx * dx + dy * dy);
-}
-
// ─── Curved band AABBs (only for curved vs curved fallback) ───
function getCurvedBandAABBs(sym: CurveParams): AABB[] {
const angle = sym.curveAngle || 90;
- const outerR = sym.w;
- const bandW = getCurveBandWidth(sym.symbolId);
- const innerR = Math.max(0, outerR - bandW);
+ const { arcCx: acx, arcCy: acy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
const rot = (sym.rotation || 0) * Math.PI / 180;
const sweepRad = (angle * Math.PI) / 180;
- const acx = sym.x;
- const acy = sym.y + sym.h;
const scx = sym.x + sym.w / 2;
const scy = sym.y + sym.h / 2;
@@ -658,50 +531,3 @@ export function checkSpacingViolation(
return false;
}
-// ─── Grid snapping ───
-
-export function snapToGrid(x: number, y: number, w?: number, h?: number, rotation?: number): { x: number; y: number } {
- if (!layout.snapEnabled) return { x, y };
- const size = layout.gridSize;
- if (!rotation || !w || !h) {
- return { x: Math.round(x / size) * size, y: Math.round(y / size) * size };
- }
- const aabb = getAABB(x, y, w, h, rotation);
- const snappedAabbX = Math.round(aabb.x / size) * size;
- const snappedAabbY = Math.round(aabb.y / size) * size;
- return {
- x: snappedAabbX + aabb.w / 2 - w / 2,
- y: snappedAabbY + aabb.h / 2 - h / 2,
- };
-}
-
-// ─── Find valid position ───
-
-export function findValidPosition(
- id: number, x: number, y: number, w: number, h: number,
- symbolId: string, rotation: number, curveAngle?: number, w2?: number
-): { x: number; y: number } {
- const snapped = snapToGrid(x, y, w, h, rotation);
- let sx = snapped.x, sy = snapped.y;
- if (!checkSpacingViolation(id, sx, sy, w, h, rotation, symbolId, curveAngle, w2)) return { x: sx, y: sy };
-
- const step = layout.snapEnabled ? layout.gridSize : 1;
- const searchRadius = 20;
- for (let r = 1; r <= searchRadius; r++) {
- for (let dx = -r; dx <= r; dx++) {
- for (let dy = -r; dy <= r; dy++) {
- if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
- const cx = sx + dx * step;
- const cy = sy + dy * step;
- const bb = getAABB(cx, cy, w, h, rotation);
- if (bb.x < 0 || bb.y < 0 || bb.x + bb.w > layout.canvasW || bb.y + bb.h > layout.canvasH) continue;
- if (!checkSpacingViolation(id, cx, cy, w, h, rotation, symbolId, curveAngle, w2)) return { x: cx, y: cy };
- }
- }
- }
- return { x: sx, y: sy };
-}
-
-export function clamp(value: number, min: number, max: number): number {
- return Math.max(min, Math.min(max, value));
-}
diff --git a/svelte-app/src/lib/canvas/distance.ts b/svelte-app/src/lib/canvas/distance.ts
new file mode 100644
index 0000000..5c24be8
--- /dev/null
+++ b/svelte-app/src/lib/canvas/distance.ts
@@ -0,0 +1,116 @@
+/** Pure distance and geometric helper functions — no state dependencies */
+
+import type { AABB } from '../types.js';
+
+export function getAABB(x: number, y: number, w: number, h: number, rotation: number): AABB {
+ if (!rotation) return { x, y, w, h };
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+ const rad = (rotation * Math.PI) / 180;
+ const cos = Math.abs(Math.cos(rad));
+ const sin = Math.abs(Math.sin(rad));
+ const bw = w * cos + h * sin;
+ const bh = w * sin + h * cos;
+ return { x: cx - bw / 2, y: cy - bh / 2, w: bw, h: bh };
+}
+
+/** Distance from a point to a line segment */
+export function distPointToSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
+ const dx = x2 - x1, dy = y2 - y1;
+ const len2 = dx * dx + dy * dy;
+ if (len2 === 0) return Math.sqrt((px - x1) ** 2 + (py - y1) ** 2);
+ const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
+ const nearX = x1 + t * dx, nearY = y1 + t * dy;
+ return Math.sqrt((px - nearX) ** 2 + (py - nearY) ** 2);
+}
+
+/**
+ * Distance from a point to an annular sector (arc band).
+ * Point is in local space: arc center at origin, Y points UP,
+ * sector sweeps CCW from angle 0 to sweepRad.
+ */
+export function distToAnnularSector(px: number, py: number, innerR: number, outerR: number, sweepRad: number): number {
+ const r = Math.sqrt(px * px + py * py);
+ let theta = Math.atan2(py, px);
+ if (theta < -0.001) theta += 2 * Math.PI;
+
+ const inAngle = theta >= -0.001 && theta <= sweepRad + 0.001;
+
+ if (inAngle) {
+ if (r >= innerR && r <= outerR) return 0;
+ if (r < innerR) return innerR - r;
+ return r - outerR;
+ }
+
+ let minDist = Infinity;
+ minDist = Math.min(minDist, distPointToSegment(px, py, innerR, 0, outerR, 0));
+
+ const ec = Math.cos(sweepRad), es = Math.sin(sweepRad);
+ minDist = Math.min(minDist, distPointToSegment(px, py, innerR * ec, innerR * es, outerR * ec, outerR * es));
+
+ const clampedTheta = Math.max(0, Math.min(sweepRad, theta < 0 ? theta + 2 * Math.PI : theta));
+ if (clampedTheta >= 0 && clampedTheta <= sweepRad) {
+ const oc = Math.cos(clampedTheta), os = Math.sin(clampedTheta);
+ minDist = Math.min(minDist, Math.sqrt((px - outerR * oc) ** 2 + (py - outerR * os) ** 2));
+ if (innerR > 0) {
+ minDist = Math.min(minDist, Math.sqrt((px - innerR * oc) ** 2 + (py - innerR * os) ** 2));
+ }
+ }
+
+ return minDist;
+}
+
+/** Distance from a point in world space to an OBB */
+export function pointToOBBDist(px: number, py: number, bx: number, by: number, bw: number, bh: number, brot: number): number {
+ const bcx = bx + bw / 2, bcy = by + bh / 2;
+ const rad = -(brot || 0) * Math.PI / 180;
+ const cos = Math.cos(rad), sin = Math.sin(rad);
+ const dx = px - bcx, dy = py - bcy;
+ const lx = dx * cos - dy * sin;
+ const ly = dx * sin + dy * cos;
+ const hw = bw / 2, hh = bh / 2;
+ const cx = Math.max(-hw, Math.min(hw, lx));
+ const cy = Math.max(-hh, Math.min(hh, ly));
+ return Math.sqrt((lx - cx) ** 2 + (ly - cy) ** 2);
+}
+
+/** Test if a point is inside a convex polygon */
+export function pointInConvexPolygon(px: number, py: number, verts: [number, number][]): boolean {
+ let sign = 0;
+ for (let i = 0; i < verts.length; i++) {
+ const [x1, y1] = verts[i];
+ const [x2, y2] = verts[(i + 1) % verts.length];
+ const cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1);
+ if (cross !== 0) {
+ if (sign === 0) sign = cross > 0 ? 1 : -1;
+ else if ((cross > 0 ? 1 : -1) !== sign) return false;
+ }
+ }
+ return true;
+}
+
+/** Distance from a point to a convex polygon (0 if inside) */
+export function pointToConvexPolygonDist(px: number, py: number, verts: [number, number][]): number {
+ if (pointInConvexPolygon(px, py, verts)) return 0;
+ let minDist = Infinity;
+ for (let i = 0; i < verts.length; i++) {
+ const [x1, y1] = verts[i];
+ const [x2, y2] = verts[(i + 1) % verts.length];
+ minDist = Math.min(minDist, distPointToSegment(px, py, x1, y1, x2, y2));
+ }
+ return minDist;
+}
+
+/** Edge distance between two axis-aligned bounding boxes */
+export function edgeDistance(
+ ax: number, ay: number, aw: number, ah: number,
+ bx: number, by: number, bw: number, bh: number
+): number {
+ const gapX = Math.max(0, Math.max(ax, bx) - Math.min(ax + aw, bx + bw));
+ const gapY = Math.max(0, Math.max(ay, by) - Math.min(ay + ah, by + bh));
+ return Math.sqrt(gapX * gapX + gapY * gapY);
+}
+
+export function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
diff --git a/svelte-app/src/lib/canvas/geometry.ts b/svelte-app/src/lib/canvas/geometry.ts
index f8a8e5a..783b96b 100644
--- a/svelte-app/src/lib/canvas/geometry.ts
+++ b/svelte-app/src/lib/canvas/geometry.ts
@@ -1,6 +1,6 @@
/** Shared geometry helpers used by both collision.ts and interactions.ts */
-export type Vec2 = [number, number];
+type Vec2 = [number, number];
/** Compute 4 corners of an oriented box anchored at a point along a direction.
* anchorSide='right': box extends backward from anchor (left box, right-center at anchor).
diff --git a/svelte-app/src/lib/canvas/grid-snap.ts b/svelte-app/src/lib/canvas/grid-snap.ts
new file mode 100644
index 0000000..181c1b6
--- /dev/null
+++ b/svelte-app/src/lib/canvas/grid-snap.ts
@@ -0,0 +1,45 @@
+/** Grid snapping and valid position finding */
+
+import { layout } from '../stores/layout.svelte.js';
+import { getAABB } from './distance.js';
+import { checkSpacingViolation } from './collision.js';
+
+export function snapToGrid(x: number, y: number, w?: number, h?: number, rotation?: number): { x: number; y: number } {
+ if (!layout.snapEnabled) return { x, y };
+ const size = layout.gridSize;
+ if (!rotation || !w || !h) {
+ return { x: Math.round(x / size) * size, y: Math.round(y / size) * size };
+ }
+ const aabb = getAABB(x, y, w, h, rotation);
+ const snappedAabbX = Math.round(aabb.x / size) * size;
+ const snappedAabbY = Math.round(aabb.y / size) * size;
+ return {
+ x: snappedAabbX + aabb.w / 2 - w / 2,
+ y: snappedAabbY + aabb.h / 2 - h / 2,
+ };
+}
+
+export function findValidPosition(
+ id: number, x: number, y: number, w: number, h: number,
+ symbolId: string, rotation: number, curveAngle?: number, w2?: number
+): { x: number; y: number } {
+ const snapped = snapToGrid(x, y, w, h, rotation);
+ let sx = snapped.x, sy = snapped.y;
+ if (!checkSpacingViolation(id, sx, sy, w, h, rotation, symbolId, curveAngle, w2)) return { x: sx, y: sy };
+
+ const step = layout.snapEnabled ? layout.gridSize : 1;
+ const searchRadius = 20;
+ for (let r = 1; r <= searchRadius; r++) {
+ for (let dx = -r; dx <= r; dx++) {
+ for (let dy = -r; dy <= r; dy++) {
+ if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
+ const cx = sx + dx * step;
+ const cy = sy + dy * step;
+ const bb = getAABB(cx, cy, w, h, rotation);
+ if (bb.x < 0 || bb.y < 0 || bb.x + bb.w > layout.canvasW || bb.y + bb.h > layout.canvasH) continue;
+ if (!checkSpacingViolation(id, cx, cy, w, h, rotation, symbolId, curveAngle, w2)) return { x: cx, y: cy };
+ }
+ }
+ }
+ return { x: sx, y: sy };
+}
diff --git a/svelte-app/src/lib/canvas/hit-testing.ts b/svelte-app/src/lib/canvas/hit-testing.ts
index 0259b68..7f31ad8 100644
--- a/svelte-app/src/lib/canvas/hit-testing.ts
+++ b/svelte-app/src/lib/canvas/hit-testing.ts
@@ -1,6 +1,6 @@
/** Pure hit-testing functions — no module state, no side effects */
-import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, EPC_CONFIG, INDUCTION_CONFIG } from '../symbols.js';
+import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, EPC_CONFIG, INDUCTION_CONFIG, getCurveGeometry } from '../symbols.js';
import { THEME } from './render-theme.js';
import type { PlacedSymbol } from '../types.js';
@@ -64,16 +64,8 @@ export function hitResizeHandle(cx: number, cy: number, sym: PlacedSymbol): 'lef
const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym);
if (isCurvedType(sym.symbolId)) {
- const arcAngle = sym.curveAngle || 90;
- const arcRad = (arcAngle * Math.PI) / 180;
- const outerR = sym.w;
- const arcCx = sym.x;
- const arcCy = sym.y + sym.h;
- const h1x = arcCx + outerR, h1y = arcCy;
- if (pointInRect(lx, ly, h1x - hs / 2, h1y - hs / 2, hs, hs)) return 'right';
- const h2x = arcCx + outerR * Math.cos(arcRad);
- const h2y = arcCy - outerR * Math.sin(arcRad);
- if (pointInRect(lx, ly, h2x - hs / 2, h2y - hs / 2, hs, hs)) return 'left';
+ const { arcCx, arcCy, outerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
+ if (pointInRect(lx, ly, arcCx + outerR - hs / 2, arcCy - hs / 2, hs, hs)) return 'right';
return null;
}
diff --git a/svelte-app/src/lib/canvas/interactions.ts b/svelte-app/src/lib/canvas/interactions.ts
index d4c067a..f8ea1f7 100644
--- a/svelte-app/src/lib/canvas/interactions.ts
+++ b/svelte-app/src/lib/canvas/interactions.ts
@@ -647,6 +647,11 @@ function onKeydown(e: KeyboardEvent) {
if (layout.selectedIds.size > 0 && (e.key === 'q' || e.key === 'Q')) {
rotateSelected(-ROTATION_STEP);
}
+
+ // M: mirror selected
+ if (layout.selectedIds.size > 0 && (e.key === 'm' || e.key === 'M')) {
+ layout.mirrorSelected();
+ }
}
// Called from Palette component to initiate a palette drag
diff --git a/svelte-app/src/lib/canvas/renderer.ts b/svelte-app/src/lib/canvas/renderer.ts
index 78f0c1d..18b2a36 100644
--- a/svelte-app/src/lib/canvas/renderer.ts
+++ b/svelte-app/src/lib/canvas/renderer.ts
@@ -1,5 +1,5 @@
import { layout } from '../stores/layout.svelte.js';
-import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getCurveBandWidth, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG } from '../symbols.js';
+import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getCurveGeometry, SPACING_EXEMPT, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG } from '../symbols.js';
import { checkSpacingViolation } from './collision.js';
import { THEME } from './render-theme.js';
import type { PlacedSymbol } from '../types.js';
@@ -98,16 +98,12 @@ function drawGrid(ctx: CanvasRenderingContext2D) {
/** Trace the arc band outline path (for selection/collision/hover strokes on curved types) */
function traceArcBandPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) {
const angle = sym.curveAngle || 90;
- const outerR = sym.w + pad;
- const bandW = getCurveBandWidth(sym.symbolId);
- const innerR = Math.max(0, sym.w - bandW - pad);
+ const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
const sweepRad = (angle * Math.PI) / 180;
- const arcCx = sym.x;
- const arcCy = sym.y + sym.h;
ctx.beginPath();
- ctx.arc(arcCx, arcCy, outerR, 0, -sweepRad, true);
- ctx.arc(arcCx, arcCy, innerR, -sweepRad, 0, false);
+ ctx.arc(arcCx, arcCy, outerR + pad, 0, -sweepRad, true);
+ ctx.arc(arcCx, arcCy, Math.max(0, innerR - pad), -sweepRad, 0, false);
ctx.closePath();
}
@@ -318,13 +314,8 @@ function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.lineWidth = THEME.resizeHandle.lineWidth;
if (isCurvedType(sym.symbolId)) {
- const arcAngle = sym.curveAngle || 90;
- const arcRad = (arcAngle * Math.PI) / 180;
- const outerR = sym.w;
- const arcCx = sym.x;
- const arcCy = sym.y + sym.h;
+ const { arcCx, arcCy, outerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
drawHandle(ctx, arcCx + outerR, arcCy, hs);
- drawHandle(ctx, arcCx + outerR * Math.cos(arcRad), arcCy - outerR * Math.sin(arcRad), hs);
} else if (isSpurType(sym.symbolId)) {
const w2 = sym.w2 ?? sym.w;
// Right handle on top base (controls w2)
@@ -391,11 +382,31 @@ function drawPhotoeye3Slice(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, im
ctx.drawImage(img, srcW - srcRightW, 0, srcRightW, srcH, sym.x + sym.w - rightCap, sym.y, rightCap, sym.h);
}
+/** Draw curved conveyor/chute programmatically with fixed band width */
+function drawCurvedSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
+ const angle = sym.curveAngle || 90;
+ const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
+ const sweepRad = (angle * Math.PI) / 180;
+
+ ctx.beginPath();
+ ctx.arc(arcCx, arcCy, outerR, 0, -sweepRad, true);
+ ctx.arc(arcCx, arcCy, innerR, -sweepRad, 0, false);
+ ctx.closePath();
+
+ ctx.fillStyle = '#000000';
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = 0.5;
+ ctx.fill();
+ ctx.stroke();
+}
+
function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boolean {
if (isEpcType(sym.symbolId)) {
drawEpcSymbol(ctx, sym);
} else if (isInductionType(sym.symbolId)) {
drawInductionSymbol(ctx, sym);
+ } else if (isCurvedType(sym.symbolId)) {
+ drawCurvedSymbol(ctx, sym);
} else {
const img = getSymbolImage(sym.file);
if (!img) return false;
@@ -459,12 +470,13 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
function drawSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.save();
- // Apply rotation once for all symbol types
- if (sym.rotation) {
+ // Apply rotation and mirror transforms
+ if (sym.rotation || sym.mirrored) {
const cx = sym.x + sym.w / 2;
const cy = sym.y + sym.h / 2;
ctx.translate(cx, cy);
- ctx.rotate((sym.rotation * Math.PI) / 180);
+ if (sym.rotation) ctx.rotate((sym.rotation * Math.PI) / 180);
+ if (sym.mirrored) ctx.scale(-1, 1);
ctx.translate(-cx, -cy);
}
diff --git a/svelte-app/src/lib/export.ts b/svelte-app/src/lib/export.ts
index e510409..9e8f351 100644
--- a/svelte-app/src/lib/export.ts
+++ b/svelte-app/src/lib/export.ts
@@ -3,6 +3,21 @@ import { isEpcType, isInductionType, EPC_CONFIG, INDUCTION_CONFIG } from './symb
import { deserializeSymbol } from './serialization.js';
import type { PlacedSymbol } from './types.js';
+/** If the SVG root has a single child (with only a transform attr), unwrap it. */
+function unwrapSingleGroup(svgEl: Element): { content: string; extraTransform: string } {
+ const children = Array.from(svgEl.children);
+ if (children.length === 1 && children[0].tagName === 'g') {
+ const innerG = children[0];
+ if (Array.from(innerG.attributes).every(a => a.name === 'transform')) {
+ return {
+ content: innerG.innerHTML,
+ extraTransform: innerG.getAttribute('transform') || ''
+ };
+ }
+ }
+ return { content: svgEl.innerHTML, extraTransform: '' };
+}
+
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -39,9 +54,11 @@ async function buildEpcSvgElements(sym: PlacedSymbol): Promise {
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 transform = `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)}) translate(${-lb.w},${-lb.h / 2}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
+ 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(` `);
- parts.push(` ${svgEl.innerHTML}`);
+ parts.push(` ${content}`);
parts.push(` `);
} catch {
// Fallback: plain rect
@@ -72,9 +89,13 @@ export async function exportSVG() {
for (const sym of layout.symbols) {
const rot = sym.rotation || 0;
+ const mirrored = sym.mirrored || false;
const cx = sym.x + sym.w / 2;
const cy = sym.y + sym.h / 2;
- const rotAttr = rot ? ` transform="rotate(${rot},${cx},${cy})"` : '';
+ 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 idAttr = ` id="${label}" inkscape:label="${label}"`;
@@ -108,13 +129,18 @@ export async function exportSVG() {
const sx = sym.w / vbW;
const sy = sym.h / vbH;
+ const { content, extraTransform } = unwrapSingleGroup(svgEl);
let transform = `translate(${sym.x},${sym.y}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
+ if (extraTransform) transform += ` ${extraTransform}`;
+ if (mirrored) {
+ transform = `translate(${cx},0) scale(-1,1) translate(${-cx},0) ${transform}`;
+ }
if (rot) {
transform = `rotate(${rot},${cx},${cy}) ${transform}`;
}
lines.push(` `);
- lines.push(` ${svgEl.innerHTML}`);
+ lines.push(` ${content}`);
lines.push(' ');
} catch (err) {
console.error('Failed to embed symbol:', sym.name, err);
diff --git a/svelte-app/src/lib/serialization.ts b/svelte-app/src/lib/serialization.ts
index ab4603a..c887c08 100644
--- a/svelte-app/src/lib/serialization.ts
+++ b/svelte-app/src/lib/serialization.ts
@@ -12,6 +12,7 @@ export interface SerializedSymbol {
h: number;
w2?: number;
rotation?: number;
+ mirrored?: boolean;
curveAngle?: number;
epcWaypoints?: EpcWaypoint[];
pdpCBs?: number[];
@@ -29,6 +30,7 @@ export function serializeSymbol(sym: PlacedSymbol): SerializedSymbol {
h: sym.h,
w2: sym.w2,
rotation: sym.rotation || undefined,
+ mirrored: sym.mirrored || undefined,
curveAngle: sym.curveAngle,
epcWaypoints: sym.epcWaypoints,
pdpCBs: sym.pdpCBs,
@@ -48,6 +50,7 @@ export function deserializeSymbol(data: SerializedSymbol, id: number): PlacedSym
h: data.h,
w2: data.w2,
rotation: data.rotation || 0,
+ mirrored: data.mirrored || false,
curveAngle: data.curveAngle,
epcWaypoints: data.epcWaypoints,
pdpCBs: data.pdpCBs,
diff --git a/svelte-app/src/lib/stores/layout.svelte.ts b/svelte-app/src/lib/stores/layout.svelte.ts
index 85e64a9..9bfbea6 100644
--- a/svelte-app/src/lib/stores/layout.svelte.ts
+++ b/svelte-app/src/lib/stores/layout.svelte.ts
@@ -181,6 +181,26 @@ class LayoutStore {
this.saveMcmState();
}
+ mirrorSymbol(id: number) {
+ const sym = this.symbols.find(s => s.id === id);
+ if (!sym) return;
+ this.pushUndo();
+ sym.mirrored = !sym.mirrored;
+ this.markDirty();
+ this.saveMcmState();
+ }
+
+ mirrorSelected() {
+ if (this.selectedIds.size === 0) return;
+ this.pushUndo();
+ for (const id of this.selectedIds) {
+ const sym = this.symbols.find(s => s.id === id);
+ if (sym) sym.mirrored = !sym.mirrored;
+ }
+ this.markDirty();
+ this.saveMcmState();
+ }
+
clearAll() {
this.pushUndo();
this.symbols = [];
diff --git a/svelte-app/src/lib/symbol-config.ts b/svelte-app/src/lib/symbol-config.ts
index 173b233..31435e2 100644
--- a/svelte-app/src/lib/symbol-config.ts
+++ b/svelte-app/src/lib/symbol-config.ts
@@ -33,6 +33,11 @@ export const PHOTOEYE_CONFIG = {
} as const;
export const CURVE_CONFIG = {
- convBand: 30, // matches conveyor height
- chuteBand: 30, // matches chute height
+ // Fractions of display size, derived from SVG viewBox "-2 -2 104 104"
+ // All curved SVGs have arc center at viewBox (0,100), outerR=95
+ centerOffsetX: 2 / 104, // arc center X offset from sym.x
+ centerOffsetY: 102 / 104, // arc center Y offset from sym.y
+ outerRFrac: 95 / 104, // outer radius as fraction of sym.w
+ convBandWidth: 30, // fixed band width — matches straight conveyor height
+ chuteBandWidth: 30, // fixed band width — matches straight chute height
} as const;
diff --git a/svelte-app/src/lib/symbols.ts b/svelte-app/src/lib/symbols.ts
index c38709f..7559134 100644
--- a/svelte-app/src/lib/symbols.ts
+++ b/svelte-app/src/lib/symbols.ts
@@ -88,9 +88,21 @@ export const SPACING_EXEMPT = new Set([
]);
+/** Get the fixed band width for a curved symbol (same as straight segment height) */
export function getCurveBandWidth(symbolId: string): number {
- if (symbolId.startsWith('curved_chute')) return CURVE_CONFIG.chuteBand;
- return CURVE_CONFIG.convBand;
+ if (symbolId.startsWith('curved_chute')) return CURVE_CONFIG.chuteBandWidth;
+ return CURVE_CONFIG.convBandWidth;
+}
+
+/** Compute arc center and radii in display coordinates.
+ * Band width is fixed — only the radius changes when resizing. */
+export function getCurveGeometry(symbolId: string, x: number, y: number, w: number, h: number) {
+ const arcCx = x + CURVE_CONFIG.centerOffsetX * w;
+ const arcCy = y + CURVE_CONFIG.centerOffsetY * h;
+ const outerR = CURVE_CONFIG.outerRFrac * w;
+ const bandW = getCurveBandWidth(symbolId);
+ const innerR = Math.max(0, outerR - bandW);
+ return { arcCx, arcCy, outerR, innerR, bandW };
}
const imageCache = new Map();
diff --git a/svelte-app/src/lib/types.ts b/svelte-app/src/lib/types.ts
index f8ff40a..be824bd 100644
--- a/svelte-app/src/lib/types.ts
+++ b/svelte-app/src/lib/types.ts
@@ -28,6 +28,7 @@ export interface PlacedSymbol {
h: number;
w2?: number; // Spur: top base width
rotation: number;
+ mirrored?: boolean; // Horizontal flip
curveAngle?: number; // For curved conveyors/chutes
epcWaypoints?: EpcWaypoint[]; // EPC editable line waypoints (local coords)
pdpCBs?: number[]; // PDP visible circuit breaker numbers (1-26)