/** * 한 번 스냅 — 점 단계에서 END · MID · CEN · QUA · INT · PER · NEA · NOD · NON 을 치면 다음 한 점만 그 스냅(오토캐드). * 이름 = 앞 세 글자 ~ 영문 전체(END · ENDP · ENDPOINT) · 우리에 없는 스냅(TAN · EXT …)은 안내. * F3(객체 스냅) 꺼져 있어도 먹음. 커서 둘레에 맞는 점이 없으면 클릭을 버리고 기다림 · Esc 로 풂. */ import type { Point } from '@flatten-js/core'; import { SNAP_POINT_DISTANCE } from '../App.consts.ts'; import { SnapPointType } from '../App.types.ts'; import { findClosestEntity } from '../helpers/find-closest-entity.ts'; import { getIntersectionPoints } from '../helpers/get-intersection-points.ts'; import { queryEntitiesNearPoint } from '../helpers/spatial-index.ts'; import { getLayerById, getScreenCanvasDrawController } from '../state.ts'; const SNAP_LABELS = { END: '끝점', MID: '중간점', CEN: '중심', QUA: '사분점', INT: '교차점', PER: '수직', NEA: '근처점', NOD: '노드', NON: '스냅 없음', } as const; type SnapName = keyof typeof SNAP_LABELS; const SNAP_TYPES: Partial> = { END: SnapPointType.LineEndPoint, MID: SnapPointType.LineMidPoint, CEN: SnapPointType.CircleCenter, QUA: SnapPointType.CircleCardinal, NOD: SnapPointType.Point, }; /** 오토캐드 영문 전체 이름 — 세 글자 줄임부터 받음 */ const FULL_NAMES: Record = { END: 'ENDPOINT', MID: 'MIDPOINT', CEN: 'CENTER', QUA: 'QUADRANT', INT: 'INTERSECTION', PER: 'PERPENDICULAR', NEA: 'NEAREST', NOD: 'NODE', NON: 'NONE', }; /** 오토캐드엔 있고 우리엔 없는 스냅 */ const ABSENT_NAMES: Record = { TAN: 'TANGENT', EXT: 'EXTENSION', INS: 'INSERTION', APP: 'APPINT', PAR: 'PARALLEL', GCE: 'GCEN', }; const byName = (table: Record, text: string): K | null => { const typed = text.trim().toUpperCase().replace(/^_/, ''); if (typed.length < 3) return null; const hit = (Object.keys(table) as K[]).find( (short) => typed.startsWith(short) && table[short].startsWith(typed) ); return hit ?? null; }; /** 친 글 → 스냅 이름(END · ENDP · ENDPOINT → END) · 아니면 null */ export const snapName = (text: string): SnapName | null => byName(FULL_NAMES, text); /** 우리에 없는 오토캐드 스냅 이름(세 글자 꼴) · 아니면 null */ export const absentSnapName = (text: string): string | null => byName(ABSENT_NAMES, text); let override: SnapName | null = null; export const getSnapOverride = (): SnapName | null => override; export const setSnapOverride = (name: SnapName | null) => { override = name; }; export const isSnapName = (text: string): text is SnapName => snapName(text) === text; /** 커서 옆 안내 줄 */ export const snapOverrideHint = (): string | null => override ? `${SNAP_LABELS[override]}(${override}) 한 번 — 점 지정` : null; const closest = (points: Point[], mouse: Point, radius: number): Point | null => { let best: Point | null = null; let bestDistance = radius; for (const point of points) { const distance = Math.hypot(point.x - mouse.x, point.y - mouse.y); if (distance <= bestDistance) { best = point; bestDistance = distance; } } return best; }; /** 한 번 스냅이 고른 점 · 맞는 점 없으면 null. base = 앞 점(PER 기준) */ export function snapOverridePoint(mouse: Point, base: Point | null): Point | null { if (override === 'NON') return mouse; // ponytail: 일반 스냅(15px)의 두 배 둘레 — 오토캐드 조준 상자 크기 설정이 필요하면 설정 모달로 const radius = (SNAP_POINT_DISTANCE * 2) / getScreenCanvasDrawController().getScreenScale(); const entities = queryEntitiesNearPoint(mouse.x, mouse.y, radius).filter( (entity) => !getLayerById(entity.layerId)?.isLocked ); const type = override ? SNAP_TYPES[override] : undefined; if (type) { const points = entities.flatMap((entity) => entity .getSnapPoints() .filter((snap) => snap.type === type) .map((snap) => snap.point) ); return closest(points, mouse, radius); } if (override === 'INT') return closest(getIntersectionPoints(entities), mouse, radius); const near = findClosestEntity(mouse, entities); if (!near.entity || near.distance > radius) return null; if (override === 'NEA') return near.segment.start; // PER — 앞 점에서 커서 아래 객체로 내린 발(선분이면 수선의 발) if (!base) return null; return near.entity.distanceTo(base)?.[1].start ?? null; }