Refactor collision/distance modules, fix curved geometry, add mirror support

- Split collision.ts (707→549): extract distance.ts (pure math) and grid-snap.ts
- Fix curved conveyor/chute outline to match SVG viewBox geometry
- Draw curves programmatically with fixed 30px band width (no SVG stretching)
- Single resize handle for curves (was 2)
- Add .gitattributes for consistent line endings
- Make Vec2 type module-private
- Add mirror transform support in renderer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
igurielidze 2026-03-21 17:21:04 +04:00
parent c5bb986a82
commit 6f0ac836fb
15 changed files with 299 additions and 219 deletions

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
* text=auto eol=lf
*.svg binary
*.xlsx binary
*.pdf binary
*.png binary
*.jpg binary

View File

@ -74,6 +74,16 @@
contextMenu = null; 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() { function handleSetLabel() {
if (!contextMenu) return; if (!contextMenu) return;
const sym = layout.symbols.find(s => s.id === contextMenu!.symId); const sym = layout.symbols.find(s => s.id === contextMenu!.symId);
@ -130,6 +140,7 @@
role="menu" role="menu"
> >
<button class="context-menu-item" onclick={handleSetLabel} role="menuitem">Set ID</button> <button class="context-menu-item" onclick={handleSetLabel} role="menuitem">Set ID</button>
<button class="context-menu-item" onclick={handleMirror} role="menuitem">Mirror</button>
<button class="context-menu-item" onclick={handleDuplicate} role="menuitem">Duplicate</button> <button class="context-menu-item" onclick={handleDuplicate} role="menuitem">Duplicate</button>
<button class="context-menu-item" onclick={handleDelete} role="menuitem">Delete</button> <button class="context-menu-item" onclick={handleDelete} role="menuitem">Delete</button>
</div> </div>

View File

@ -1,19 +1,12 @@
import type { AABB, EpcWaypoint } from '../types.js'; 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 { 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';
export function getAABB(x: number, y: number, w: number, h: number, rotation: number): AABB { // Re-export for consumers that import from collision.ts
if (!rotation) return { x, y, w, h }; export { getAABB, clamp } from './distance.js';
const cx = x + w / 2; export { snapToGrid, findValidPosition } from './grid-snap.js';
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 };
}
// ─── OBB collision via Separating Axis Theorem ─── // ─── OBB collision via Separating Axis Theorem ───
@ -124,99 +117,6 @@ function polygonSATWithSpacing(
} }
/** Check if point is inside a convex polygon (CCW or CW winding) */ /** 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 ─── // ─── Curved vs OBB: true geometric check ───
interface CurveParams { interface CurveParams {
@ -234,23 +134,15 @@ function checkCurvedVsOBB(
spacing: number spacing: number
): boolean { ): boolean {
const angle = curve.curveAngle || 90; const angle = curve.curveAngle || 90;
const outerR = curve.w; const { arcCx: acx, arcCy: acy, outerR, innerR } = getCurveGeometry(curve.symbolId, curve.x, curve.y, curve.w, curve.h);
const bandW = getCurveBandWidth(curve.symbolId);
const innerR = Math.max(0, outerR - bandW);
const curveRot = (curve.rotation || 0) * Math.PI / 180; const curveRot = (curve.rotation || 0) * Math.PI / 180;
const sweepRad = (angle * 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 scx = curve.x + curve.w / 2;
const scy = curve.y + curve.h / 2; const scy = curve.y + curve.h / 2;
const { toLocal, toWorld } = createCurveTransforms(acx, acy, scx, scy, curveRot); 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 otherCx = bx + bw / 2, otherCy = by + bh / 2;
const oRad = (brot || 0) * Math.PI / 180; const oRad = (brot || 0) * Math.PI / 180;
const oCos = Math.cos(oRad), oSin = Math.sin(oRad); const oCos = Math.cos(oRad), oSin = Math.sin(oRad);
@ -305,14 +197,10 @@ function checkCurvedVsSpur(
spacing: number spacing: number
): boolean { ): boolean {
const angle = curve.curveAngle || 90; const angle = curve.curveAngle || 90;
const outerR = curve.w; const { arcCx: acx, arcCy: acy, outerR, innerR } = getCurveGeometry(curve.symbolId, curve.x, curve.y, curve.w, curve.h);
const bandW = getCurveBandWidth(curve.symbolId);
const innerR = Math.max(0, outerR - bandW);
const curveRot = (curve.rotation || 0) * Math.PI / 180; const curveRot = (curve.rotation || 0) * Math.PI / 180;
const sweepRad = (angle * 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 scx = curve.x + curve.w / 2;
const scy = curve.y + curve.h / 2; const scy = curve.y + curve.h / 2;
@ -350,28 +238,13 @@ function checkCurvedVsSpur(
return false; 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) ─── // ─── Curved band AABBs (only for curved vs curved fallback) ───
function getCurvedBandAABBs(sym: CurveParams): AABB[] { function getCurvedBandAABBs(sym: CurveParams): AABB[] {
const angle = sym.curveAngle || 90; const angle = sym.curveAngle || 90;
const outerR = sym.w; const { arcCx: acx, arcCy: acy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
const bandW = getCurveBandWidth(sym.symbolId);
const innerR = Math.max(0, outerR - bandW);
const rot = (sym.rotation || 0) * Math.PI / 180; const rot = (sym.rotation || 0) * Math.PI / 180;
const sweepRad = (angle * 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 scx = sym.x + sym.w / 2;
const scy = sym.y + sym.h / 2; const scy = sym.y + sym.h / 2;
@ -658,50 +531,3 @@ export function checkSpacingViolation(
return false; 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));
}

View File

@ -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));
}

View File

@ -1,6 +1,6 @@
/** Shared geometry helpers used by both collision.ts and interactions.ts */ /** 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. /** 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). * anchorSide='right': box extends backward from anchor (left box, right-center at anchor).

View File

@ -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 };
}

View File

@ -1,6 +1,6 @@
/** Pure hit-testing functions — no module state, no side effects */ /** 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 { THEME } from './render-theme.js';
import type { PlacedSymbol } from '../types.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); const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym);
if (isCurvedType(sym.symbolId)) { if (isCurvedType(sym.symbolId)) {
const arcAngle = sym.curveAngle || 90; const { arcCx, arcCy, outerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
const arcRad = (arcAngle * Math.PI) / 180; if (pointInRect(lx, ly, arcCx + outerR - hs / 2, arcCy - hs / 2, hs, hs)) return 'right';
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';
return null; return null;
} }

View File

@ -647,6 +647,11 @@ function onKeydown(e: KeyboardEvent) {
if (layout.selectedIds.size > 0 && (e.key === 'q' || e.key === 'Q')) { if (layout.selectedIds.size > 0 && (e.key === 'q' || e.key === 'Q')) {
rotateSelected(-ROTATION_STEP); 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 // Called from Palette component to initiate a palette drag

View File

@ -1,5 +1,5 @@
import { layout } from '../stores/layout.svelte.js'; 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 { checkSpacingViolation } from './collision.js';
import { THEME } from './render-theme.js'; import { THEME } from './render-theme.js';
import type { PlacedSymbol } from '../types.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) */ /** Trace the arc band outline path (for selection/collision/hover strokes on curved types) */
function traceArcBandPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) { function traceArcBandPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) {
const angle = sym.curveAngle || 90; const angle = sym.curveAngle || 90;
const outerR = sym.w + pad; const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
const bandW = getCurveBandWidth(sym.symbolId);
const innerR = Math.max(0, sym.w - bandW - pad);
const sweepRad = (angle * Math.PI) / 180; const sweepRad = (angle * Math.PI) / 180;
const arcCx = sym.x;
const arcCy = sym.y + sym.h;
ctx.beginPath(); ctx.beginPath();
ctx.arc(arcCx, arcCy, outerR, 0, -sweepRad, true); ctx.arc(arcCx, arcCy, outerR + pad, 0, -sweepRad, true);
ctx.arc(arcCx, arcCy, innerR, -sweepRad, 0, false); ctx.arc(arcCx, arcCy, Math.max(0, innerR - pad), -sweepRad, 0, false);
ctx.closePath(); ctx.closePath();
} }
@ -318,13 +314,8 @@ function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.lineWidth = THEME.resizeHandle.lineWidth; ctx.lineWidth = THEME.resizeHandle.lineWidth;
if (isCurvedType(sym.symbolId)) { if (isCurvedType(sym.symbolId)) {
const arcAngle = sym.curveAngle || 90; const { arcCx, arcCy, outerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h);
const arcRad = (arcAngle * Math.PI) / 180;
const outerR = sym.w;
const arcCx = sym.x;
const arcCy = sym.y + sym.h;
drawHandle(ctx, arcCx + outerR, arcCy, hs); drawHandle(ctx, arcCx + outerR, arcCy, hs);
drawHandle(ctx, arcCx + outerR * Math.cos(arcRad), arcCy - outerR * Math.sin(arcRad), hs);
} else if (isSpurType(sym.symbolId)) { } else if (isSpurType(sym.symbolId)) {
const w2 = sym.w2 ?? sym.w; const w2 = sym.w2 ?? sym.w;
// Right handle on top base (controls w2) // 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); 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 { function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boolean {
if (isEpcType(sym.symbolId)) { if (isEpcType(sym.symbolId)) {
drawEpcSymbol(ctx, sym); drawEpcSymbol(ctx, sym);
} else if (isInductionType(sym.symbolId)) { } else if (isInductionType(sym.symbolId)) {
drawInductionSymbol(ctx, sym); drawInductionSymbol(ctx, sym);
} else if (isCurvedType(sym.symbolId)) {
drawCurvedSymbol(ctx, sym);
} else { } else {
const img = getSymbolImage(sym.file); const img = getSymbolImage(sym.file);
if (!img) return false; if (!img) return false;
@ -459,12 +470,13 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
function drawSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { function drawSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.save(); ctx.save();
// Apply rotation once for all symbol types // Apply rotation and mirror transforms
if (sym.rotation) { if (sym.rotation || sym.mirrored) {
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;
ctx.translate(cx, cy); 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); ctx.translate(-cx, -cy);
} }

View File

@ -3,6 +3,21 @@ import { isEpcType, isInductionType, EPC_CONFIG, INDUCTION_CONFIG } from './symb
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. */
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) { function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@ -39,9 +54,11 @@ async function buildEpcSvgElements(sym: PlacedSymbol): Promise<string> {
const [vbX, vbY, vbW, vbH] = vb ? vb.split(/[\s,]+/).map(Number) : [0, 0, lb.w, lb.h]; const [vbX, vbY, vbW, vbH] = vb ? vb.split(/[\s,]+/).map(Number) : [0, 0, lb.w, lb.h];
const sx = lb.w / vbW; const sx = lb.w / vbW;
const sy = lb.h / vbH; 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(` <g transform="${transform}">`); parts.push(` <g transform="${transform}">`);
parts.push(` ${svgEl.innerHTML}`); parts.push(` ${content}`);
parts.push(` </g>`); parts.push(` </g>`);
} catch { } catch {
// Fallback: plain rect // Fallback: plain rect
@ -72,9 +89,13 @@ export async function exportSVG() {
for (const sym of layout.symbols) { for (const sym of layout.symbols) {
const rot = sym.rotation || 0; const rot = sym.rotation || 0;
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;
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 label = sym.label || sym.name;
const idAttr = ` id="${label}" inkscape:label="${label}"`; const idAttr = ` id="${label}" inkscape:label="${label}"`;
@ -108,13 +129,18 @@ 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);
let transform = `translate(${sym.x},${sym.y}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`; 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) { if (rot) {
transform = `rotate(${rot},${cx},${cy}) ${transform}`; transform = `rotate(${rot},${cx},${cy}) ${transform}`;
} }
lines.push(` <g${idAttr} transform="${transform}">`); lines.push(` <g${idAttr} transform="${transform}">`);
lines.push(` ${svgEl.innerHTML}`); lines.push(` ${content}`);
lines.push(' </g>'); lines.push(' </g>');
} catch (err) { } catch (err) {
console.error('Failed to embed symbol:', sym.name, err); console.error('Failed to embed symbol:', sym.name, err);

View File

@ -12,6 +12,7 @@ export interface SerializedSymbol {
h: number; h: number;
w2?: number; w2?: number;
rotation?: number; rotation?: number;
mirrored?: boolean;
curveAngle?: number; curveAngle?: number;
epcWaypoints?: EpcWaypoint[]; epcWaypoints?: EpcWaypoint[];
pdpCBs?: number[]; pdpCBs?: number[];
@ -29,6 +30,7 @@ export function serializeSymbol(sym: PlacedSymbol): SerializedSymbol {
h: sym.h, h: sym.h,
w2: sym.w2, w2: sym.w2,
rotation: sym.rotation || undefined, rotation: sym.rotation || undefined,
mirrored: sym.mirrored || undefined,
curveAngle: sym.curveAngle, curveAngle: sym.curveAngle,
epcWaypoints: sym.epcWaypoints, epcWaypoints: sym.epcWaypoints,
pdpCBs: sym.pdpCBs, pdpCBs: sym.pdpCBs,
@ -48,6 +50,7 @@ export function deserializeSymbol(data: SerializedSymbol, id: number): PlacedSym
h: data.h, h: data.h,
w2: data.w2, w2: data.w2,
rotation: data.rotation || 0, rotation: data.rotation || 0,
mirrored: data.mirrored || false,
curveAngle: data.curveAngle, curveAngle: data.curveAngle,
epcWaypoints: data.epcWaypoints, epcWaypoints: data.epcWaypoints,
pdpCBs: data.pdpCBs, pdpCBs: data.pdpCBs,

View File

@ -181,6 +181,26 @@ class LayoutStore {
this.saveMcmState(); 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() { clearAll() {
this.pushUndo(); this.pushUndo();
this.symbols = []; this.symbols = [];

View File

@ -33,6 +33,11 @@ export const PHOTOEYE_CONFIG = {
} as const; } as const;
export const CURVE_CONFIG = { export const CURVE_CONFIG = {
convBand: 30, // matches conveyor height // Fractions of display size, derived from SVG viewBox "-2 -2 104 104"
chuteBand: 30, // matches chute height // 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; } as const;

View File

@ -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 { export function getCurveBandWidth(symbolId: string): number {
if (symbolId.startsWith('curved_chute')) return CURVE_CONFIG.chuteBand; if (symbolId.startsWith('curved_chute')) return CURVE_CONFIG.chuteBandWidth;
return CURVE_CONFIG.convBand; 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<string, HTMLImageElement>(); const imageCache = new Map<string, HTMLImageElement>();

View File

@ -28,6 +28,7 @@ export interface PlacedSymbol {
h: number; h: number;
w2?: number; // Spur: top base width w2?: number; // Spur: top base width
rotation: number; rotation: number;
mirrored?: boolean; // Horizontal flip
curveAngle?: number; // For curved conveyors/chutes curveAngle?: number; // For curved conveyors/chutes
epcWaypoints?: EpcWaypoint[]; // EPC editable line waypoints (local coords) epcWaypoints?: EpcWaypoint[]; // EPC editable line waypoints (local coords)
pdpCBs?: number[]; // PDP visible circuit breaker numbers (1-26) pdpCBs?: number[]; // PDP visible circuit breaker numbers (1-26)