Files
Aislo/B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts
T
eomsangdonandClaude Opus 5.5 8661501dae feat(CAD): 337-1 가-2 점 물음 보조 · ↑↓ 기록 · Tab 후보 — FROM · M2P · @ · # · * · 스냅 이름 늘림 · H11
- 새 inputController/input-history.ts — 쉴 때 = 친 명령 기록 · 명령 중 = 그 물음 최근 값 20 · 알림 줄 안 섞음(H11) · 후보 돌림
- 새 inputController/point-modifiers.ts — FROM(기준점 → <간격띄우기>) · M2P/MTP · @ = 마지막 점 · #·* 절대 좌표 · 스냅 이름
- 스냅 이름 — 세 글자 ~ 영문 전체(END · ENDP · ENDPOINT) · QUA · NOD 더함 · TAN · EXT · INS · APP · PAR · GCE 는 「이 CAD 에 없음」 안내(N14)
- 사다리 — 기준점 없는 첫 점 @dx,dy = 마지막 점 기준 · 옵션 영문 전체 딱 맞음은 옵션 먼저(CENTER) · 줄임은 스냅(CEN) · 덧물음 base
- ↑↓ = 기록 · 후보 · Shift+↑↓ = 화면 옮김 · Tab(쉼 + 친 글) = 후보 돌림 · 명령행도 같은 함수
- 시험 — prompt-frame 가-2 8 · keyboard-chain PLINE 첫 점 줄 손질

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 00:04:30 +09:00

121 lines
4.5 KiB
TypeScript

/**
* 한 번 스냅 — 점 단계에서 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<Record<SnapName, SnapPointType>> = {
END: SnapPointType.LineEndPoint,
MID: SnapPointType.LineMidPoint,
CEN: SnapPointType.CircleCenter,
QUA: SnapPointType.CircleCardinal,
NOD: SnapPointType.Point,
};
/** 오토캐드 영문 전체 이름 — 세 글자 줄임부터 받음 */
const FULL_NAMES: Record<SnapName, string> = {
END: 'ENDPOINT',
MID: 'MIDPOINT',
CEN: 'CENTER',
QUA: 'QUADRANT',
INT: 'INTERSECTION',
PER: 'PERPENDICULAR',
NEA: 'NEAREST',
NOD: 'NODE',
NON: 'NONE',
};
/** 오토캐드엔 있고 우리엔 없는 스냅 */
const ABSENT_NAMES: Record<string, string> = {
TAN: 'TANGENT',
EXT: 'EXTENSION',
INS: 'INSERTION',
APP: 'APPINT',
PAR: 'PARALLEL',
GCE: 'GCEN',
};
const byName = <K extends string>(table: Record<K, string>, 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;
}