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;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<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={handleDelete} role="menuitem">Delete</button>
|
||||
</div>
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
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 */
|
||||
|
||||
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).
|
||||
|
||||
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 */
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 <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) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
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 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(` <g transform="${transform}">`);
|
||||
parts.push(` ${svgEl.innerHTML}`);
|
||||
parts.push(` ${content}`);
|
||||
parts.push(` </g>`);
|
||||
} 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(` <g${idAttr} transform="${transform}">`);
|
||||
lines.push(` ${svgEl.innerHTML}`);
|
||||
lines.push(` ${content}`);
|
||||
lines.push(' </g>');
|
||||
} catch (err) {
|
||||
console.error('Failed to embed symbol:', sym.name, err);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<string, HTMLImageElement>();
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user