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:
parent
c5bb986a82
commit
6f0ac836fb
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.svg binary
|
||||||
|
*.xlsx binary
|
||||||
|
*.pdf binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
|
|||||||
116
svelte-app/src/lib/canvas/distance.ts
Normal file
116
svelte-app/src/lib/canvas/distance.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -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).
|
||||||
|
|||||||
45
svelte-app/src/lib/canvas/grid-snap.ts
Normal file
45
svelte-app/src/lib/canvas/grid-snap.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 = [];
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user