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)