diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts index 14e078a66..a4bae8470 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts @@ -17,6 +17,7 @@ import { chamferToolStateMachine, filletToolStateMachine, } from '../tools/modify/corner-tools'; +import { lengthenToolStateMachine } from '../tools/modify/lengthen-tool'; import { drawOrderBackToolStateMachine, drawOrderFrontToolStateMachine, @@ -32,11 +33,10 @@ import { extendToolStateMachine, joinToolStateMachine, } from '../tools/modify/structure-tools'; +import { offsetToolStateMachine } from '../tools/modify/offset-tool'; import { alignToolStateMachine, - lengthenToolStateMachine, mirrorToolStateMachine, - offsetToolStateMachine, stretchToolStateMachine, } from '../tools/modify/transform-tools'; import { moveToolStateMachine } from '../tools/move-tool'; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts index 7550c0ba0..044259c38 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts @@ -6,7 +6,7 @@ import { bumpSceneVersion } from '../helpers/scene-version'; import { getScreenCanvasDrawController, setEntities, getEntities } from '../state'; import { Tool } from '../tools'; import { selectToolStateMachine } from '../tools/select-tool'; -import { zoomExtents } from '../inputController/zoom-prompt'; +import { startZoomPrompt } from '../inputController/zoom-prompt'; const zoomBy = (factor: number): string => { const controller = getScreenCanvasDrawController(); @@ -43,10 +43,10 @@ export const VIEW_COMMANDS: CadCommand[] = [ label: '범위 줌', aliases: ['Z'], glyph: '⛶', - hint: '도면 전체가 보이도록 화면을 맞춘다', + hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 단추 = 바로 범위)', run: () => { - zoomExtents(); // 앞 화면 기억(이전(P)) - return '도면 전체 보기'; + startZoomPrompt(); // 덧물음 — 명령 중 'Z 도 같은 길 + return ''; }, }, { diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx index c149ff424..5d878f70d 100644 --- a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -3,17 +3,20 @@ import { type FC, type FormEvent, useMemo, useRef, useState } from 'react'; import { describeCommand, matchCommandPrefixes } from '../commands/registry'; import { getCommandHistory, runCommandInput } from '../commands/run-command'; import { activeToolWantsFreeText, isPlainSpace } from '../inputController/ime-sink'; +import { commandCandidate, recallInput } from '../inputController/input-history'; import { currentPrompt } from '../inputController/prompt-input'; import { getInputController } from '../state'; export const CommandLine: FC = () => { const [text, setText] = useState(''); - const [historyIndex, setHistoryIndex] = useState(null); + /** Tab · ↑↓ 후보 돌림 — 처음 친 글 · 몇째(null = 안 돌림) */ + const [pick, setPick] = useState<{ base: string; index: number } | null>(null); + const [fromHistory, setFromHistory] = useState(false); const inputRef = useRef(null); const history = getCommandHistory(); const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.'; - const suggestions = useMemo(() => matchCommandPrefixes(text).slice(0, 8), [text]); + const suggestions = useMemo(() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8), [text, pick]); const submit = (event: FormEvent) => { event.preventDefault(); @@ -26,17 +29,28 @@ export const CommandLine: FC = () => { // 진행 중인 명령이 문자·숫자를 기다리면 그쪽으로, 아니면 명령으로 해석된다 getInputController().submitText(value); setText(''); - setHistoryIndex(null); + setPick(null); + setFromHistory(false); }; - const recallHistory = (direction: -1 | 1) => { - if (!history.length) return; - const nextIndex = - historyIndex === null - ? history.length - 1 - : Math.min(history.length - 1, Math.max(0, historyIndex + direction)); - setHistoryIndex(nextIndex); - setText(history[nextIndex].split(' ')[0]); + /** 후보 보이면 후보 돌림 · 아니면 친 명령 · 최근 값 기록(캔버스와 같은 함수 · 알림 줄 안 섞음) */ + const recall = (direction: -1 | 1) => { + if (pick || (!fromHistory && text.trim() && suggestions.length)) { + cycle(direction); + return; + } + const value = recallInput(direction, text); + if (value === null) return; + setText(value); + setFromHistory(true); + }; + const cycle = (direction: -1 | 1) => { + const base = pick?.base ?? text; + const index = pick ? pick.index + direction : direction > 0 ? 0 : -1; + const name = commandCandidate(base, index); + if (!name) return; + setPick({ base, index }); + setText(name); }; return ( @@ -53,7 +67,11 @@ export const CommandLine: FC = () => { id="cad-command" ref={inputRef} value={text} - onChange={(event) => setText(event.target.value)} + onChange={(event) => { + setText(event.target.value); + setPick(null); + setFromHistory(false); + }} onKeyDown={(event) => { if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫 if (event.key === ' ' && !activeToolWantsFreeText()) { @@ -68,17 +86,13 @@ export const CommandLine: FC = () => { event.preventDefault(); getInputController().handleEscapeKey(); } - if (event.key === 'ArrowUp') { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); - recallHistory(-1); - } - if (event.key === 'ArrowDown') { - event.preventDefault(); - recallHistory(1); + recall(event.key === 'ArrowUp' ? -1 : 1); } if (event.key === 'Tab' && suggestions.length) { event.preventDefault(); - setText(suggestions[0].id); + cycle(1); } }} placeholder="명령 입력 (예: L, LINE, REC, MOVE)" diff --git a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx index 75d0baa20..679be9f7b 100644 --- a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx +++ b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx @@ -1,6 +1,7 @@ /** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */ import type { FC } from 'react'; import { runCommandInput } from '../commands/run-command'; +import { redoCommand, undoCommand } from '../inputController/prompt-input'; import { zoomExtents } from '../inputController/zoom-prompt'; import { getScreenCanvasDrawController, @@ -18,10 +19,10 @@ export const ViewControls: FC = () => {
{showUndo && ( <> - - diff --git a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts index 08504b3b9..36f3e211b 100644 --- a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts @@ -51,6 +51,8 @@ export class MeasurementEntity implements Entity { private startPoint: Point; private endPoint: Point; private offsetPoint: Point; + /** 치수 문자 바꿈(DIMLINEAR 문자(T)) — `<>` = 잰 값 · 없으면 잰 값 */ + public textOverride?: string; constructor(layerId: string, startPoint: Point, endPoint: Point, offsetPoint: Point) { this.layerId = layerId; @@ -75,6 +77,17 @@ export class MeasurementEntity implements Entity { this.offsetPoint = point; } + /** 잰 값(치수 소수 자릿수) */ + public getMeasuredText(): string { + return String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())); + } + + /** 보이는 치수 문자 */ + public getLabel(): string { + const measured = this.getMeasuredText(); + return this.textOverride ? this.textOverride.replaceAll('<>', measured) : measured; + } + public getDrawPoints() { // Return if measurement is zero length if (isPointEqual(this.startPoint, this.endPoint)) { @@ -301,7 +314,7 @@ export class MeasurementEntity implements Entity { drawController.drawLine(offsetStartPointMargin, offsetStartPointExtend); drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend); - const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())); + const distance = this.getLabel(); const originalTextDirection = normalUnit.rotate90CW(); let finalTextDirection = originalTextDirection; if ( @@ -343,12 +356,14 @@ export class MeasurementEntity implements Entity { } public clone(): MeasurementEntity { - return new MeasurementEntity( + const copy = new MeasurementEntity( getActiveLayerId(), this.startPoint.clone(), this.endPoint.clone(), this.offsetPoint.clone() ); + copy.textOverride = this.textOverride; + return copy; } public intersectsWithBox(box: Box): boolean { @@ -416,7 +431,7 @@ export class MeasurementEntity implements Entity { ]; // Calculate text properties - const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())); + const distance = this.getLabel(); const worldFactor = annotationWorldFactor(); const textHeight = getDimTextHeight() / worldFactor; // Estimate width: textString.length * fontSize * aspectRatioFactor @@ -582,6 +597,7 @@ export class MeasurementEntity implements Entity { startPoint: { x: this.startPoint.x, y: this.startPoint.y }, endPoint: { x: this.endPoint.x, y: this.endPoint.y }, offsetPoint: { x: this.offsetPoint.x, y: this.offsetPoint.y }, + ...(this.textOverride ? { textOverride: this.textOverride } : {}), }, }; } @@ -611,6 +627,7 @@ export class MeasurementEntity implements Entity { measurementEntity.lineColor = jsonEntity.lineColor; measurementEntity.lineWidth = jsonEntity.lineWidth; measurementEntity.lineDash = jsonEntity.lineDash; + measurementEntity.textOverride = jsonEntity.shapeData.textOverride; return measurementEntity; } } @@ -619,4 +636,5 @@ export interface MeasurementJsonData { startPoint: { x: number; y: number }; endPoint: { x: number; y: number }; offsetPoint: { x: number; y: number }; + textOverride?: string; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts index e715c829f..1bebb63db 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts @@ -29,7 +29,7 @@ export function getPointFromEvent(startPoint: Point | null, event: PointInputEve : mouse; } if (event.type === ActorEvent.MOUSE_CLICK) { - const clickEvent = event as MouseClickEvent & { snapped?: boolean }; + const clickEvent = event as MouseClickEvent; const click = clickEvent.worldMouseLocation; if (locked) return projectOnLock(startPoint, click); return startPoint && !clickEvent.snapped && orthoOn(!!clickEvent.holdingShift) diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index a90301703..1210819e1 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -38,9 +38,11 @@ import { cadCtrlCommand, ctrlShortcut } from './input-shortcut.ts'; import { wheelZoom } from './input-wheel.ts'; import { isObjectSnap, noteShift, snapAt } from './ortho.ts'; import { isWaitingForSecondSelectPoint, selectingActor } from './selecting.ts'; +import { openSnapMenu } from './snap-menu.ts'; import { isMiddleDouble, zoomExtents } from './zoom-prompt.ts'; import { attachTouch } from './input-touch.ts'; import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts'; +import { commandCandidate, pushInput, recallInput } from './input-history.ts'; import { angleLockHint, getAngleLock, @@ -74,6 +76,10 @@ const DRAG_SELECT_MIN_PX = 4; export class InputController { private text = ''; + /** ↑↓ · Tab 돌림 — 기록인가 후보인가 · 후보의 처음 친 글 · 몇째 */ + private browsing: 'history' | 'candidates' | null = null; + private candidateBase = ''; + private candidateIndex = 0; /** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */ private leftPressLocation: Point | null = null; public readonly ime = new ImeSink((data) => { @@ -163,10 +169,14 @@ export class InputController { public handleMouseUp(evt: MouseEvent) { if (evt.button === MouseButton.Right) { - // 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) + // 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) · Shift+우클릭 = 점 물음에서 한 번 스냅 메뉴 evt.preventDefault(); evt.stopPropagation(); - this.handleEnterKey(true); + if (evt.shiftKey && currentPrompt().kind === 'point') { + openSnapMenu(evt.clientX, evt.clientY, (name) => this.submitText(name)); + } else { + this.handleEnterKey(true); + } } // If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas @@ -408,7 +418,12 @@ export class InputController { return; } if (evt.key === 'Tab') { - // 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동 + // 쉼 + 친 글 = 명령 후보 돌림 · 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동 + if (isIdle() && this.text) { + evt.preventDefault(); + this.cycleCandidate(1); + return; + } if (!dynamicInputApplies()) return; evt.preventDefault(); const isNumber = NUMBER_REGEXP.test(this.text); @@ -445,6 +460,7 @@ export class InputController { // Remove the last character from the input field evt.preventDefault(); this.text = this.text.slice(0, this.text.length - 1); + this.browsing = null; } else if (evt.key === 'Delete') { // User wants to delete the current selection evt.preventDefault(); @@ -464,6 +480,9 @@ export class InputController { this.handleEnterKey(true); this.ime.sync(activeToolWantsIme()); } + } else if ((evt.key === 'ArrowUp' || evt.key === 'ArrowDown') && !evt.shiftKey) { + // ↑↓ = 기록 · 최근 값 · 후보(오토캐드) — 화면 옮김은 Shift+↑↓ · ← → + this.recall(evt.key === 'ArrowUp' ? -1 : 1); } else if (evt.key === 'ArrowDown') { // Move the screen down getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey)); @@ -483,12 +502,40 @@ export class InputController { // 글 단계(TEXT·MTEXT)는 친 그대로 — 명령 입력일 때만 대문자로 바꾼다(보이는 글만 · 맞추기는 prompt.ts). // Ctrl · Alt · ⌘ 와 함께 누른 글자는 쌓지 않음(Ctrl+C → 원이 되던 것). this.text += activeToolWantsFreeText() ? evt.key : evt.key.toUpperCase(); + this.browsing = null; } } + /** ↑↓ — 후보 보일 때 = 후보 돌림 · 아니면 기록(쉼 = 친 명령 · 명령 중 = 그 물음 최근 값) */ + private recall(dir: -1 | 1) { + const candidatesShown = isIdle() && !!this.text && !!commandCandidate(this.text, 0); + if (this.browsing === 'candidates' || (this.browsing === null && candidatesShown)) { + this.cycleCandidate(dir); + return; + } + const value = recallInput(dir, this.text); + if (value === null) return; + this.text = value; + this.browsing = 'history'; + } + + /** 명령 후보 돌림 — 처음 친 글로 시작하는 명령 이름을 차례로 친 글 자리에 */ + private cycleCandidate(dir: -1 | 1) { + if (this.browsing !== 'candidates') { + this.candidateBase = this.text; + this.candidateIndex = dir > 0 ? -1 : 0; + } + const name = commandCandidate(this.candidateBase, this.candidateIndex + dir); + if (!name) return; + this.candidateIndex += dir; + this.text = name; + this.browsing = 'candidates'; + } + /** Esc 한 번 = 명령 끝(오토캐드) — 친 글 · 조합 · 각도 잠금 · 한 번 스냅 · 덧물음도 함께 풂 */ public handleEscapeKey() { this.text = ''; + this.browsing = null; this.ime.composing = ''; const boxing = this.isSelectToolActive() && @@ -511,7 +558,12 @@ export class InputController { if (this.text === '' || !isIdle()) { return []; } - return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand); + const picking = this.browsing === 'candidates'; + const candidates = matchCommandPrefixes(picking ? this.candidateBase : this.text); + const picked = picking ? candidates.findIndex((command) => command.id === this.text) : -1; + return candidates + .slice(0, 6) + .map((command, index) => `${index === picked ? '▸ ' : ''}${describeCommand(command)}`); } /** @@ -521,6 +573,8 @@ export class InputController { public handleEnterKey(_repeatIfIdle = false) { const text = this.text; this.text = ''; + this.browsing = null; + pushInput(text); // ↑↓ 기록(지금 물음 묶음 — 해석 전에) routeTyped(text); } diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-history.ts b/B07_DesignDetail/openwebcad/src/inputController/input-history.ts new file mode 100644 index 000000000..1067864fd --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-history.ts @@ -0,0 +1,52 @@ +/** + * ↑↓ 기록(337 설계 2-12 · H11) — 쉴 때 = 친 명령 기록 · 명령 중 = 그 물음(`prompt.history` · 없으면 글)의 최근 값 20. + * 보이는 기록 줄(run-command 의 log · 알림 섞임)과 나눔. 캔버스 · 명령행이 같이 씀. 글 물음(TEXT 등)은 안 남김. + */ +import { matchCommandPrefixes, resolveCommandInput } from '../commands/registry'; +import { currentPrompt } from './prompt-input'; + +const COMMANDS = '\u0000명령'; +const LIMIT = { command: 50, value: 20 }; + +const groups = new Map(); +let browsing: { group: string; index: number } | null = null; + +/** 지금 물음의 기록 묶음 · 글 물음 · 모름 = null */ +function groupNow(): string | null { + const prompt = currentPrompt(); + if (prompt.kind === 'idle') return COMMANDS; + if (prompt.kind === 'text' || prompt.kind === null) return null; + return prompt.spec?.history ?? prompt.spec?.text ?? prompt.line; +} + +/** 제출한 글 하나를 지금 물음 기록에 — 쉴 때는 명령이 된 글만 */ +export function pushInput(text: string): void { + browsing = null; + const value = text.trim(); + const group = groupNow(); + if (!value || !group) return; + if (group === COMMANDS && !resolveCommandInput(value)) return; + const list = groups.get(group) ?? []; + if (list[list.length - 1] !== value) list.push(value); + list.splice(0, Math.max(0, list.length - (group === COMMANDS ? LIMIT.command : LIMIT.value))); + groups.set(group, list); +} + +/** ↑(-1) · ↓(+1) — 지금 물음 기록에서 한 칸 · 끝을 지나면 빈 글 · 기록 없으면 null(친 글 그대로) */ +export function recallInput(dir: -1 | 1, _text: string): string | null { + const group = groupNow(); + const list = group ? groups.get(group) : undefined; + if (!group || !list?.length) return null; + const from = browsing?.group === group ? browsing.index : list.length; + const index = Math.min(list.length, Math.max(0, from + dir)); + browsing = { group, index }; + return index === list.length ? '' : list[index]; +} + +/** 명령 후보 돌림(Tab · 후보 보일 때 ↑↓) — base 로 시작하는 명령 중 index 번째(돌아감) */ +export function commandCandidate(base: string, index: number): string | null { + const candidates = matchCommandPrefixes(base); + if (!candidates.length) return null; + const n = candidates.length; + return candidates[((index % n) + n) % n].id; +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts b/B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts index 276f017c5..ad5ba7d74 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts @@ -1,5 +1,6 @@ /** - * 한 번 스냅 — 점 단계에서 END · MID · CEN · INT · PER · NEA · NON 을 치면 다음 한 점만 그 스냅(오토캐드). + * 한 번 스냅 — 점 단계에서 END · MID · CEN · QUA · INT · PER · NEA · NOD · NON 을 치면 다음 한 점만 그 스냅(오토캐드). + * 이름 = 앞 세 글자 ~ 영문 전체(END · ENDP · ENDPOINT) · 우리에 없는 스냅(TAN · EXT …)은 안내. * F3(객체 스냅) 꺼져 있어도 먹음. 커서 둘레에 맞는 점이 없으면 클릭을 버리고 기다림 · Esc 로 풂. */ import type { Point } from '@flatten-js/core'; @@ -14,9 +15,11 @@ const SNAP_LABELS = { END: '끝점', MID: '중간점', CEN: '중심', + QUA: '사분점', INT: '교차점', PER: '수직', NEA: '근처점', + NOD: '노드', NON: '스냅 없음', } as const; type SnapName = keyof typeof SNAP_LABELS; @@ -25,15 +28,52 @@ 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 => text in SNAP_LABELS; +export const isSnapName = (text: string): text is SnapName => snapName(text) === text; /** 커서 옆 안내 줄 */ export const snapOverrideHint = (): string | null => diff --git a/B07_DesignDetail/openwebcad/src/inputController/point-modifiers.ts b/B07_DesignDetail/openwebcad/src/inputController/point-modifiers.ts new file mode 100644 index 000000000..e9ee029e4 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/point-modifiers.ts @@ -0,0 +1,83 @@ +/** + * 점 물음 보조(337 설계 1-4 · 2-5 ④ · 가-2) — 한 번 스냅 이름 · FROM · M2P(MTP) · `@`(마지막 점) · `#` · `*`(절대 좌표). + * FROM · M2P 는 덧물음 두 개를 잇고 끝에 고른 점을 원래 물음에 넘김(도구 머신은 모름 → 투명). + */ +import { Point } from '@flatten-js/core'; +import { lastPoint, normalizeKeyword } from '../commands/prompt'; +import { ActorEvent } from '../tools/tool.types'; +import { parsePointText } from './input-patterns'; +import { absentSnapName, setSnapOverride, snapName } from './input-snap-override'; +import { distanceCursor, notice, pushSubPrompt, sendPoint } from './prompt-input'; + +const point = (x: number, y: number) => new Point(x, y); + +/** FROM — 「기준점:」 → 「<간격띄우기>:」(`@dx,dy` · 거리 = 기준점에서 커서 쪽) */ +function startFrom() { + pushSubPrompt({ + prompt: { text: '기준점' }, + kind: 'point', + onPoint: (base) => { + pushSubPrompt({ + prompt: { text: '<간격띄우기>' }, + kind: 'point', + base, + onPoint: (target) => ({ point: target }), + }); + return 'done'; + }, + }); +} + +/** M2P · MTP — 두 점의 가운데 */ +function startMidBetween() { + pushSubPrompt({ + prompt: { text: '중간점의 첫 번째 점' }, + kind: 'point', + onPoint: (first) => { + pushSubPrompt({ + prompt: { text: '중간점의 두 번째 점' }, + kind: 'point', + base: first, + onPoint: (second) => ({ point: point((first.x + second.x) / 2, (first.y + second.y) / 2) }), + }); + return 'done'; + }, + }); +} + +/** 점 물음에서 친 글이 보조면 처리하고 true · 아니면 false(옵션 · 좌표 차례로) */ +export function tryPointModifier(text: string): boolean { + const typed = normalizeKeyword(text); + const snap = snapName(typed); + if (snap) { + setSnapOverride(snap); + return true; + } + const absent = absentSnapName(typed); + if (absent) { + notice(`「${absent}」 스냅은 이 CAD 에 없음.`); + return true; + } + if (typed === 'FROM' || typed === 'FRO') { + startFrom(); + return true; + } + if (typed === 'M2P' || typed === 'MTP') { + startMidBetween(); + return true; + } + if (typed === '@') { + const last = lastPoint(); + if (!last) return false; + sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: point(last.x, last.y) }); + return true; + } + if (typed.startsWith('#') || typed.startsWith('*')) { + // 절대 좌표 강제 — `#x,y` · `*d { subs.length = 0; }; -function settle(result: SubResult | undefined) { +/** 덧물음 sub 의 답 — 'done' · {point} 면 그 덧물음만 닫음(답 안에서 다음 덧물음을 열어도 됨) */ +function settle(sub: SubPrompt, result: SubResult | undefined) { if (!result || result === 'stay') return; - subs.pop(); + const at = subs.lastIndexOf(sub); + if (at >= 0) subs.splice(at, 1); if (result !== 'done') sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: result.point }); } @@ -181,21 +188,32 @@ export const distanceEvent = (value: number): PointTextEvent => ({ export const dynamicInputApplies = (): boolean => currentPrompt().kind === 'point' && !!getAngleGuideOriginPoint(); +/** base 에서 to 쪽으로 distance 간 점 */ +const toward = (base: Point, to: Point, distance: number): Point => { + const length = Math.hypot(to.x - base.x, to.y - base.y) || 1; + return new Point( + base.x + ((to.x - base.x) / length) * distance, + base.y + ((to.y - base.y) / length) * distance + ); +}; + /** 점 하나 — 덧물음 먼저(마우스 누름도) · 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */ export function sendPoint(event: PointTextEvent | MouseClickEvent): void { const sub = subPrompt(); if (sub) { + const last = lastPoint(); + const base = sub.base ?? getAngleGuideOriginPoint() ?? (last && new Point(last.x, last.y)); if (event.type === ActorEvent.NUMBER_INPUT) { - if (sub.onNumber) settle(sub.onNumber(event.value)); + if (sub.onNumber) settle(sub, sub.onNumber(event.value)); + else if (base) settle(sub, sub.onPoint?.(toward(base, event.worldMouseLocation, event.value))); else reject(currentPrompt()); } else if (event.type === ActorEvent.MOUSE_CLICK) { - settle(sub.onPoint?.(event.worldMouseLocation)); + settle(sub, sub.onPoint?.(event.worldMouseLocation)); } else if (event.type === ActorEvent.RELATIVE_POINT_INPUT) { - const base = getAngleGuideOriginPoint() ?? lastPoint(); - if (base) settle(sub.onPoint?.(new Point(base.x + event.value.x, base.y + event.value.y))); + if (base) settle(sub, sub.onPoint?.(new Point(base.x + event.value.x, base.y + event.value.y))); else reject(currentPrompt()); } else { - settle(sub.onPoint?.(event.value)); + settle(sub, sub.onPoint?.(event.value)); } } else { getActiveToolActor()?.send(event); @@ -206,13 +224,13 @@ export function sendPoint(event: PointTextEvent | MouseClickEvent): void { function sendOption(key: string) { const sub = subPrompt(); - if (sub) settle(sub.onOption?.(key)); + if (sub) settle(sub, sub.onOption?.(key)); else send({ type: `OPTION.${key}` }); } function sendEnter() { const sub = subPrompt(); - if (sub) settle(sub.onEnter?.()); + if (sub) settle(sub, sub.onEnter?.()); else send({ type: ActorEvent.ENTER }); } @@ -275,7 +293,8 @@ function routeEmpty(prompt: CurrentPrompt) { if (fallback?.kind === 'value') { const value = defaultValue(fallback); if (typeof value === 'number') { - if (subPrompt()?.onNumber) settle(subPrompt()?.onNumber?.(value)); + const sub = subPrompt(); + if (sub?.onNumber) settle(sub, sub.onNumber(value)); else sendPoint(distanceEvent(value)); return; } @@ -316,12 +335,7 @@ function routePointAid(text: string): boolean { setAngleLock(Number.parseFloat(lock[1])); return true; } - const upper = normalizeKeyword(text); - if (isSnapName(upper)) { - setSnapOverride(upper); - return true; - } - return false; + return tryPointModifier(text); // 스냅 이름 · FROM · M2P · @ · # · * } /** ④ 좌표 · 거리 */ @@ -329,6 +343,13 @@ function routePointText(text: string, prompt: CurrentPrompt) { const event = parsePointText(text, distanceCursor); if (!event) return reject(prompt); const hasBase = !!getAngleGuideOriginPoint() || !!subPrompt(); + if (event.type === ActorEvent.RELATIVE_POINT_INPUT && !hasBase) { + // 기준점 없는 첫 점 `@dx,dy` = 마지막 점(LASTPOINT) 기준 + const last = lastPoint(); + if (!last) return reject(prompt); + const value = new Point(last.x + event.value.x, last.y + event.value.y); + return sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value }); + } if (event.type !== ActorEvent.ABSOLUTE_POINT_INPUT && !hasBase) return reject(prompt); if (event.type === ActorEvent.NUMBER_INPUT) { const problem = numberProblem(event.value, prompt.spec?.number); @@ -344,7 +365,7 @@ function routeNumber(text: string, prompt: CurrentPrompt) { const problem = numberProblem(value, prompt.spec?.number); if (problem) return reject(prompt, problem); const sub = subPrompt(); - if (sub) settle(sub.onNumber?.(value)); + if (sub) settle(sub, sub.onNumber?.(value)); else send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: distanceCursor() }); } @@ -358,6 +379,9 @@ export function routeTyped(raw: string): void { return; } if (text.startsWith("'")) return routeTransparent(text.slice(1)); + const textSub = subPrompt(); + const own = textSub?.onText?.(text); + if (textSub && own) return settle(textSub, own); if (prompt.kind === 'idle') { // ⑨ 이름 > 내 별칭 > 기본 별칭 딱 맞음만 const command = resolveCommandInput(text); @@ -365,9 +389,12 @@ export function routeTyped(raw: string): void { else notice(`알 수 없는 명령 「${normalizeKeyword(text)}」.`); return; } - if (prompt.kind === 'point' && routePointAid(text)) return; - // ⑤ 옵션(숨김 포함) · 없는 옵션 안내 const hit = matchOption(text, prompt.spec); + // 옵션 kw · 영문 전체와 딱 같으면 옵션 먼저(ARC 「CENTER」) · 줄임은 스냅 먼저(「CEN」 = 스냅) + const exact = + hit && 'option' in hit && [hit.option.kw, hit.option.en].includes(normalizeKeyword(text)); + if (prompt.kind === 'point' && !exact && routePointAid(text)) return; + // ⑤ 옵션(숨김 포함) · 없는 옵션 안내 if (hit && 'option' in hit) return sendOption(hit.option.key); if (hit && 'absent' in hit) { notice(`「${hit.absent.label}(${hit.absent.kw})」 옵션은 이 CAD 에 없음.`); diff --git a/B07_DesignDetail/openwebcad/src/inputController/selecting.ts b/B07_DesignDetail/openwebcad/src/inputController/selecting.ts index f095a6ccb..3205b0869 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/selecting.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/selecting.ts @@ -6,6 +6,11 @@ import type { AnyActorRef } from 'xstate'; import { getActiveToolActor } from '../state.ts'; import { Tool } from '../tools.ts'; import { SelectState } from '../tools/select-tool.ts'; +import { setKeywordNotice } from '../tools/select-tool.helpers.ts'; +import { notice } from './prompt-input.ts'; + +// 고르기 키워드(ALL · L · P) 「n개를 찾음」 → 알림 줄 +setKeywordNotice(notice); const isSelect = (actor: AnyActorRef | undefined): boolean => actor?.getSnapshot()?.context?.type === Tool.SELECT; diff --git a/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css new file mode 100644 index 000000000..8d313cc24 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css @@ -0,0 +1,29 @@ +/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 커서 자리에 세로 목록 */ +.cad-snap-menu { + position: fixed; + z-index: 60; + display: flex; + flex-direction: column; + min-width: 160px; + padding: 4px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface-raised); + box-shadow: var(--shadow-sm); +} + +.cad-snap-menu > button { + padding: 4px 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-text); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.cad-snap-menu > button:hover, +.cad-snap-menu > button:focus-visible { + background: var(--color-surface-hover, rgba(127, 127, 127, 0.15)); +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts new file mode 100644 index 000000000..bfb33b789 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts @@ -0,0 +1,76 @@ +/** + * Shift+우클릭 한 번 스냅 메뉴(337 마 · 오토캐드) — 점 물음일 때만 · 고르면 그 이름을 친 것과 같은 길(`submitText`). + * 스냅 이름 · FROM · M2P 의 뜻은 친 글 사다리(`prompt-input.ts`) 몫 · 메뉴는 고르기만. + */ +import './snap-menu.css'; + +/** [친 글, 이름] — 오토캐드 객체 스냅 메뉴 차례 */ +export const SNAP_MENU: readonly [string, string][] = [ + ['FROM', '시작점에서'], + ['M2P', '두 점 사이의 중간'], + ['END', '끝점'], + ['MID', '중간점'], + ['INT', '교차점'], + ['CEN', '중심'], + ['QUA', '사분점'], + ['PER', '수직'], + ['NOD', '노드'], + ['NEA', '근처점'], + ['NON', '없음'], +]; + +let current: { onPick: (name: string) => void; element: HTMLElement | null } | null = null; + +const onKey = (evt: KeyboardEvent) => { + if (evt.key !== 'Escape') return; + evt.preventDefault(); + evt.stopImmediatePropagation(); // Esc = 메뉴만 닫음(명령은 그대로) + closeSnapMenu(); +}; +const onOutside = (evt: MouseEvent) => { + if (!current?.element?.contains(evt.target as Node)) closeSnapMenu(); +}; + +export const isSnapMenuOpen = (): boolean => current !== null; + +export function closeSnapMenu(): void { + if (!current) return; + current.element?.remove(); + current = null; + if (typeof window === 'undefined') return; + window.removeEventListener('keydown', onKey, true); + window.removeEventListener('mousedown', onOutside, true); +} + +/** 메뉴에서 하나 고름 — 닫고 그 이름을 넘김 */ +export function pickSnapMenu(name: string): void { + const picked = current; + closeSnapMenu(); + picked?.onPick(name); +} + +/** 화면 (x, y) 에 메뉴를 띄움 — DOM 없는 시험에선 상태만 */ +export function openSnapMenu(x: number, y: number, onPick: (name: string) => void): void { + closeSnapMenu(); + current = { onPick, element: null }; + if (typeof document === 'undefined') return; + const menu = document.createElement('div'); + menu.className = 'cad-snap-menu controls'; + menu.setAttribute('role', 'menu'); + for (const [name, label] of SNAP_MENU) { + const item = document.createElement('button'); + item.type = 'button'; + item.setAttribute('role', 'menuitem'); + item.textContent = `${label} (${name})`; + item.addEventListener('click', () => pickSnapMenu(name)); + menu.append(item); + } + document.body.append(menu); + // 화면 밖으로 안 나가게 + const box = menu.getBoundingClientRect(); + menu.style.left = `${Math.min(x, window.innerWidth - box.width - 4)}px`; + menu.style.top = `${Math.min(y, window.innerHeight - box.height - 4)}px`; + current.element = menu; + window.addEventListener('keydown', onKey, true); + window.addEventListener('mousedown', onOutside, true); +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/zoom-prompt.ts b/B07_DesignDetail/openwebcad/src/inputController/zoom-prompt.ts index d8146e02e..f17b6b848 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/zoom-prompt.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/zoom-prompt.ts @@ -1,9 +1,12 @@ /** - * 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위. + * 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위 · ZOOM 덧물음. * 앞 화면 기억 = ZOOM · 휠 두 번 · ⛶ 로 바꾼 것만(휠 굴림 · 팬은 안 쌓음) · 오토캐드처럼 열 개. + * ZOOM 은 덧물음 — 도구를 안 바꿔 명령 중 `'Z` 도 같은 길(투명). */ -import type { Point } from '@flatten-js/core'; +import { Point } from '@flatten-js/core'; +import type { OptionSpec, PromptSpec } from '../commands/prompt.ts'; import { getScreenCanvasDrawController } from '../state.ts'; +import { notice, pushSubPrompt, type SubPrompt, type SubResult } from './prompt-input.ts'; type View = { scale: number; offset: Point }; @@ -46,3 +49,110 @@ export function isMiddleDouble(point: { x: number; y: number }, time = Date.now( lastMiddle = double ? null : { time, x: point.x, y: point.y }; return double; } + +/** 배율만 바꿈 — 화면 가운데 자리를 붙잡음 */ +function setScaleAboutCenter(scale: number): void { + const controller = getScreenCanvasDrawController(); + const size = controller.getCanvasSize(); + const offset = controller.getScreenOffset(); + const old = controller.getScreenScale(); + const cx = offset.x + size.x / 2 / old; + const cy = offset.y + size.y / 2 / old; + controller.setScreenScale(scale); + controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale)); +} + +/** 윈도우 줌 — 두 구석 상자를 화면에 꽉(종횡비 유지) */ +export function zoomWindow(a: Point, b: Point): void { + const controller = getScreenCanvasDrawController(); + const size = controller.getCanvasSize(); + const width = Math.abs(a.x - b.x); + const height = Math.abs(a.y - b.y); + const scale = Math.min( + width > 0 ? size.x / width : Number.POSITIVE_INFINITY, + height > 0 ? size.y / height : Number.POSITIVE_INFINITY + ); + if (!Number.isFinite(scale)) return; // 같은 점 두 번 — 안 바꿈 + const cx = (a.x + b.x) / 2; + const cy = (a.y + b.y) / 2; + changeView(() => { + controller.setScreenScale(scale); + controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale)); + }); +} + +const zoomOption = (key: string, kw: string, label: string): OptionSpec => ({ + key, + kw, + en: key, + label, +}); + +/** 오토캐드 ZOOM 첫 줄 — 넣은 넷 · 뺀 넷 · <실시간> */ +const ZOOM_PROMPT: PromptSpec = { + text: '윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력', + options: [ + zoomOption('ALL', 'A', '전체'), + zoomOption('EXTENTS', 'E', '범위'), + zoomOption('PREVIOUS', 'P', '이전'), + zoomOption('WINDOW', 'W', '윈도우'), + ], + absent: [ + zoomOption('CENTER', 'C', '중심'), + zoomOption('DYNAMIC', 'D', '동적'), + zoomOption('SCALE', 'S', '축척'), + zoomOption('OBJECT', 'O', '객체'), + ], + default: { kind: 'enter', label: '실시간' }, +}; +const FIRST_CORNER: PromptSpec = { text: '첫 번째 구석 지정' }; +const OTHER_CORNER: PromptSpec = { text: '반대 구석 지정' }; +const SCALE_REGEXP = /^(\d*\.?\d+)\s*XP?$/i; + +/** ZOOM — 덧물음 하나가 단계(첫 줄 → 첫 구석 → 반대 구석)를 품음 */ +export function startZoomPrompt(): void { + let stage: 'main' | 'first' | 'other' = 'main'; + let corner: Point | null = null; + const sub: SubPrompt = { + kind: 'point', + get prompt() { + return stage === 'main' ? ZOOM_PROMPT : stage === 'first' ? FIRST_CORNER : OTHER_CORNER; + }, + onPoint(point): SubResult { + if (stage === 'other' && corner) { + zoomWindow(corner, point); + return 'done'; + } + corner = point; // 첫 줄에서 누름 = 윈도우 첫 구석(오토캐드) + stage = 'other'; + return 'stay'; + }, + onOption(key): SubResult { + if (key === 'WINDOW') { + stage = 'first'; + return 'stay'; + } + if (key === 'PREVIOUS') { + if (!zoomPrevious()) notice('앞 화면이 없음.'); + return 'done'; + } + zoomExtents(); // 전체(A) = 범위(이 CAD 엔 도면 한계 없음) + return 'done'; + }, + onEnter(): SubResult { + if (stage !== 'main') return 'stay'; + notice('실시간 줌은 휠로.'); + return 'done'; + }, + onText(text) { + const match = stage === 'main' ? SCALE_REGEXP.exec(text) : null; + if (!match) return undefined; + const factor = Number.parseFloat(match[1]); + if (factor <= 0) return undefined; + const scale = getScreenCanvasDrawController().getScreenScale() * factor; + changeView(() => setScaleAboutCenter(scale)); + return 'done'; + }, + }; + pushSubPrompt(sub); +} diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts index 174c26035..3776892b5 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts @@ -1,6 +1,12 @@ -/** 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절) */ +/** + * 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절). + * 오토캐드 물음 차례(337 설계 1-2) — DIMRADIUS · DIMDIAMETER = 「호 또는 원 선택:」 → + * 「치수선의 위치 지정 또는 [문자(T)]:」 · 한 번 뒤 끝 · 그 밖 치수 = 물음 글만. + */ import { Arc, Circle, Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; +import { OPT, type OptionSpec } from '../../commands/prompt'; +import { logLine } from '../../commands/run-command'; import { getDimDecimals } from '../../commands/dim-settings'; import type { Entity } from '../../entities/Entity'; import { MeasurementEntity } from '../../entities/MeasurementEntity'; @@ -8,7 +14,7 @@ import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sampl import { addEntities, getEntities, setEntities, setSelectedEntityIds } from '../../state'; import { Tool } from '../../tools'; import { arcEntity, lineEntity } from '../factories/entity-factory'; -import { createSequenceTool } from '../factories/sequence-tool'; +import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool'; import { annotationText, arrowHead, dimensionText } from './annotation.helpers'; interface CircularShape { @@ -24,7 +30,24 @@ function circularOf(entity: Entity): CircularShape | null { return null; } -function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): Entity[] | null { +/** 잰 값 글(「R10.00」 · 「Ø20.00」) */ +function radialLabel(entity: Entity, diameter: boolean): string | null { + const circular = circularOf(entity); + if (!circular) return null; + const value = diameter ? circular.radius * 2 : circular.radius; + return `${diameter ? 'Ø' : 'R'}${value.toFixed(getDimDecimals())}`; +} + +/** 바꾼 글(`<>` = 잰 값) · 없으면 잰 값 */ +const labelWith = (measured: string, text?: string) => + text && text !== '<>' ? text.replaceAll('<>', measured) : measured; + +function radialDimension( + entity: Entity, + textPoint: Point, + diameter: boolean, + text?: string +): Entity[] | null { const circular = circularOf(entity); if (!circular) return null; @@ -43,52 +66,71 @@ function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): E return [ lineEntity(start, textPoint), arrowHead(edge, circular.center), - dimensionText( - diameter ? circular.radius * 2 : circular.radius, - textPoint, - diameter ? 'Ø' : 'R' - ), + text && text !== '<>' + ? annotationText(labelWith(radialLabel(entity, diameter) as string, text), textPoint) + : dimensionText( + diameter ? circular.radius * 2 : circular.radius, + textPoint, + diameter ? 'Ø' : 'R' + ), ]; } -export const dimRadiusToolStateMachine = createSequenceTool({ - tool: Tool.DIMRADIUS, - steps: [ - { kind: 'entity', instructions: '반지름을 기입할 원 또는 호를 선택하십시오.' }, - { kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' }, - ], - commit: (input) => { - const parts = radialDimension(input.entity(0), input.point(1), false); - if (!parts) { - toast.warn('원 또는 호를 선택하십시오.'); - return; - } - addEntities(parts, true); - }, -}); +const MTEXT: OptionSpec = { key: 'MTEXT', kw: 'M', en: 'MTEXT', label: '여러 줄 문자' }; +const ANGLE: OptionSpec = { key: 'ANGLE', kw: 'A', en: 'ANGLE', label: '각도' }; -export const dimDiameterToolStateMachine = createSequenceTool({ - tool: Tool.DIMDIAMETER, - steps: [ - { kind: 'entity', instructions: '지름을 기입할 원 또는 호를 선택하십시오.' }, - { kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' }, - ], - commit: (input) => { - const parts = radialDimension(input.entity(0), input.point(1), true); - if (!parts) { - toast.warn('원 또는 호를 선택하십시오.'); - return; - } - addEntities(parts, true); - }, -}); +/** DIMRADIUS · DIMDIAMETER — 원 · 호 → [문자(T)] → 위치 */ +function radialTool(tool: Tool, diameter: boolean) { + const measured = (input: SequenceInput) => + input.has('obj') ? radialLabel(input.get('obj'), diameter) : null; + return createSequenceTool({ + tool, + steps: [ + { kind: 'entity', id: 'obj', prompt: { text: '호 또는 원 선택' } }, + { + kind: 'text', + id: 'txt', + when: (input) => input.mode === 'text', + next: 'place', + defaultValue: '<>', + prompt: (input) => { + const value = measured(input); + return { + text: '치수 문자 입력', + ...(value === null ? {} : { default: { kind: 'enter' as const, label: value } }), + }; + }, + }, + { + kind: 'point', + id: 'place', + prompt: { text: '치수선의 위치 지정', options: [OPT.TEXT], absent: [MTEXT, ANGLE] }, + options: { TEXT: { mode: 'text', next: 'txt' } }, + }, + ], + commit: (input) => { + const entity = input.get('obj'); + const text = input.has('txt') ? input.get('txt') : undefined; + const parts = radialDimension(entity, input.get('place'), diameter, text); + if (!parts) { + toast.warn('원 또는 호를 선택하십시오.'); + return; + } + addEntities(parts, true); + logLine(`치수 문자 = ${labelWith(radialLabel(entity, diameter) as string, text)}`); + }, + }); +} + +export const dimRadiusToolStateMachine = radialTool(Tool.DIMRADIUS, false); +export const dimDiameterToolStateMachine = radialTool(Tool.DIMDIAMETER, true); export const dimAngularToolStateMachine = createSequenceTool({ tool: Tool.DIMANGULAR, steps: [ - { kind: 'point', instructions: '각의 꼭짓점을 지정하십시오.' }, - { kind: 'point', instructions: '첫 번째 변 위의 점을 지정하십시오.' }, - { kind: 'point', instructions: '두 번째 변 위의 점을 지정하십시오.' }, + { kind: 'point', prompt: { text: '각도 정점 지정' } }, + { kind: 'point', prompt: { text: '첫 번째 각도 끝점 지정' } }, + { kind: 'point', prompt: { text: '두 번째 각도 끝점 지정' } }, ], commit: (input) => { const [vertex, first, second] = input.points(); @@ -128,8 +170,8 @@ export const dimAngularToolStateMachine = createSequenceTool({ export const dimArcToolStateMachine = createSequenceTool({ tool: Tool.DIMARC, steps: [ - { kind: 'entity', instructions: '호 길이를 기입할 호를 선택하십시오.' }, - { kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' }, + { kind: 'entity', prompt: { text: '호 또는 폴리선 호 세그먼트 선택' } }, + { kind: 'point', prompt: { text: '호 길이 치수 위치 지정' } }, ], commit: (input) => { const entity = input.entity(0); @@ -146,8 +188,8 @@ export const dimArcToolStateMachine = createSequenceTool({ export const dimOrdinateToolStateMachine = createSequenceTool({ tool: Tool.DIMORDINATE, steps: [ - { kind: 'point', instructions: '좌표를 기입할 피처 위치를 지정하십시오.' }, - { kind: 'point', instructions: '지시선 끝점을 지정하십시오.' }, + { kind: 'point', prompt: { text: '피쳐 위치 지정' } }, + { kind: 'point', prompt: { text: '지시선 끝점 지정' } }, ], commit: (input) => { const [feature, leaderEnd] = input.points(); diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts index c29a53dae..02c0a5c51 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts @@ -1,27 +1,55 @@ -/** 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절) */ +/** + * 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절). + * 오토캐드 물음 차례(337 설계 1-2) — DIMLINEAR · DIMALIGNED = 원점 둘(빈 Enter = 객체 선택) → 치수선 위치 + * [문자(T)/수평(H)/수직(V)] · 한 번 뒤 끝 · DIMCONTINUE · DIMBASELINE = 원점 되풀이 [명령 취소(U)] · 빈 Enter 끝. + */ import { Circle, Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; -import { EntityName } from '../../entities/Entity'; +import { assign, createMachine } from 'xstate'; +import { type OptionSpec, type PromptSpec, OPT, SELECT_OBJECTS } from '../../commands/prompt'; +import { logLine } from '../../commands/run-command'; +import { EntityName, type Entity } from '../../entities/Entity'; import { MeasurementEntity } from '../../entities/MeasurementEntity'; +import type { PolyLineEntity } from '../../entities/PolyLineEntity'; import { sampleEntityPoints } from '../../helpers/geometry/sample-entity'; -import { addEntities, getActiveLayerId, setSelectedEntityIds } from '../../state'; +import { getPointFromEvent } from '../../helpers/get-point-from-event'; +import { notice } from '../../inputController/prompt-input'; +import { + addEntities, + getActiveLayerId, + setAngleGuideOriginPoint, + setSelectedEntityIds, + setShouldDrawHelpers, + undo, +} from '../../state'; import { Tool } from '../../tools'; import { styled } from '../factories/entity-factory'; -import { createSequenceTool } from '../factories/sequence-tool'; +import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool'; +import { END_STATE, type PointInputEvent, type StateEvent, type ToolContext } from '../tool.types'; import { getLastDimension, rememberDimension } from './annotation.helpers'; /** 치수 객체 하나 만들기 — 현재 선 특성을 입히고 직전 치수로 기억한다 */ -function makeDimension(start: Point, end: Point, offset: Point): MeasurementEntity { +function makeDimension(start: Point, end: Point, offset: Point, text?: string): MeasurementEntity { const dimension = styled(new MeasurementEntity(getActiveLayerId(), start, end, offset)); + if (text && text !== '<>') dimension.textOverride = text; rememberDimension(dimension); return dimension; } +type Fixed = 'H' | 'V' | null; + /** - * 선형 치수 — 치수선을 놓은 방향으로 수평/수직을 고른다. + * 선형 치수 — 수평(H) · 수직(V) 고정이면 그 방향, 아니면 치수선을 놓은 방향으로 고른다. * 위·아래에 놓으면 가로 거리, 좌·우에 놓으면 세로 거리를 잰다 (AutoCAD와 같다). */ -function projectForLinear(start: Point, end: Point, offset: Point): [Point, Point] { +function projectForLinear( + start: Point, + end: Point, + offset: Point, + fixed: Fixed = null +): [Point, Point] { + if (fixed === 'H') return [start, new Point(end.x, start.y)]; + if (fixed === 'V') return [start, new Point(start.x, end.y)]; const horizontalSpan = Math.abs(end.x - start.x); const verticalSpan = Math.abs(end.y - start.y); const offsetIsVertical = @@ -32,89 +60,268 @@ function projectForLinear(start: Point, end: Point, offset: Point): [Point, Poin return [start, new Point(start.x, end.y)]; } -export const dimLinearToolStateMachine = createSequenceTool({ - tool: Tool.DIMLINEAR, - steps: [ - { kind: 'point', instructions: '첫 번째 치수보조선 원점을 지정하십시오.' }, - { kind: 'point', instructions: '두 번째 치수보조선 원점을 지정하십시오.' }, - { kind: 'point', instructions: '치수선 위치를 지정하십시오.' }, - ], - preview: (input) => { - const points = input.points(); - if (points.length !== 2) return []; - const [start, end] = projectForLinear(points[0], points[1], input.cursor); - return [new MeasurementEntity(getActiveLayerId(), start, end, input.cursor)]; - }, - commit: (input) => { - const [first, second, offset] = input.points(); - const [start, end] = projectForLinear(first, second, offset); - addEntities([makeDimension(start, end, offset)], true); - }, -}); - -export const dimAlignedToolStateMachine = createSequenceTool({ - tool: Tool.DIMALIGNED, - steps: [ - { kind: 'point', instructions: '첫 번째 치수보조선 원점을 지정하십시오.' }, - { kind: 'point', instructions: '두 번째 치수보조선 원점을 지정하십시오.' }, - { kind: 'point', instructions: '치수선 위치를 지정하십시오.' }, - ], - preview: (input) => { - const points = input.points(); - if (points.length !== 2) return []; - return [new MeasurementEntity(getActiveLayerId(), points[0], points[1], input.cursor)]; - }, - commit: (input) => { - const [start, end, offset] = input.points(); - addEntities([makeDimension(start, end, offset)], true); - }, -}); - -export const dimBaselineToolStateMachine = createSequenceTool({ - tool: Tool.DIMBASELINE, - steps: [{ kind: 'point', instructions: '다음 치수보조선 원점을 지정하십시오.' }], - commit: (input) => { - const previous = getLastDimension(); - if (!previous) { - toast.warn('먼저 치수를 하나 작성하십시오.'); - return; - } - const start = previous.getStartPoint(); - const offset = previous.getOffsetPoint(); - // 기준선 치수는 같은 시작점에서 재고, 치수선을 한 칸 더 띄운다 - const spacing = offset.distanceTo(start)[0] * 0.35 || 10; - const direction = new Point(offset.x - start.x, offset.y - start.y); - const length = Math.hypot(direction.x, direction.y) || 1; - const nextOffset = new Point( - offset.x + (direction.x / length) * spacing, - offset.y + (direction.y / length) * spacing +/** 객체 선택 — 선 · 폴리선(누른 토막) · 그 밖은 처음 · 끝 점 */ +function endsOf(entity: Entity, pick: Point): [Point, Point] | null { + let target = entity; + if (entity.getType() === EntityName.PolyLine) { + const pieces = (entity as PolyLineEntity).getEntities(); + target = pieces.reduce( + (best, piece) => + (piece.getShape()?.distanceTo(pick)[0] ?? Number.POSITIVE_INFINITY) < + (best.getShape()?.distanceTo(pick)[0] ?? Number.POSITIVE_INFINITY) + ? piece + : best, + pieces[0] ?? entity ); - addEntities([makeDimension(start, input.point(0), nextOffset)], true); - }, -}); + } + const points = sampleEntityPoints(target); + if (points.length < 2) return null; + return [points[0], points[points.length - 1]]; +} -export const dimContinueToolStateMachine = createSequenceTool({ - tool: Tool.DIMCONTINUE, - steps: [{ kind: 'point', instructions: '다음 치수보조선 원점을 지정하십시오.' }], - commit: (input) => { - const previous = getLastDimension(); - if (!previous) { - toast.warn('먼저 치수를 하나 작성하십시오.'); - return; - } - addEntities( - [makeDimension(previous.getEndPoint(), input.point(0), previous.getOffsetPoint())], - true - ); - }, +const option = (key: string, kw: string, label: string): OptionSpec => ({ + key, + kw, + en: key, + label, }); +/** 빈 Enter = 객체 선택 — 숨은 옵션(그림은 <객체 선택>) */ +const OBJECT = { ...option('OBJECT', 'OBJECT', '객체 선택'), hidden: true as const }; +const HORIZONTAL = option('HORIZONTAL', 'H', '수평'); +const VERTICAL = option('VERTICAL', 'V', '수직'); +const MTEXT = option('MTEXT', 'M', '여러 줄 문자'); +const ANGLE = option('ANGLE', 'A', '각도'); +const ROTATED = option('ROTATED', 'R', '회전됨'); +const SELECT = option('SELECT', 'S', '선택'); + +/** 원점 둘 또는 객체 끝 */ +function originsOf(input: SequenceInput): [Point, Point] | null { + if (input.has('obj')) { + const entity = input.get('obj'); + return endsOf(entity, input.pick(input.values.indexOf(entity))); + } + if (!input.has('p2')) return null; + return [input.get('p1'), input.get('p2')]; +} + +/** 수평 · 수직 고정 — 명령 하나 동안(확정 · Esc 에서 풂) */ +let fixed: Fixed = null; + +/** DIMLINEAR · DIMALIGNED 공통 물음 */ +function linearSteps(tool: Tool, aligned: boolean) { + const measure = (input: SequenceInput, offset: Point): [Point, Point] | null => { + const origins = originsOf(input); + if (!origins) return null; + return aligned ? origins : projectForLinear(origins[0], origins[1], offset, fixed); + }; + const measured = (input: SequenceInput): string | null => { + const origins = originsOf(input); + if (!origins) return null; + const [a, b] = origins; + const wide = Math.abs(b.x - a.x) >= Math.abs(b.y - a.y); + const [start, end] = aligned ? origins : projectForLinear(a, b, b, fixed ?? (wide ? 'H' : 'V')); + return new MeasurementEntity(getActiveLayerId(), start, end, end).getMeasuredText(); + }; + return createSequenceTool({ + tool, + steps: [ + { + kind: 'point', + id: 'p1', + prompt: { + text: '첫 번째 치수보조선 원점 지정 또는', + options: [OBJECT], + default: { kind: 'option', key: 'OBJECT', label: '객체 선택' }, + }, + options: { OBJECT: { mode: 'object', next: 'obj' } }, + }, + { + kind: 'entity', + id: 'obj', + when: (input) => input.mode === 'object' && !input.has('obj'), + next: 'place', + prompt: { text: '치수 기입할 객체 선택' }, + }, + { + kind: 'point', + id: 'p2', + when: (input) => !input.has('obj'), + prompt: { text: '두 번째 치수보조선 원점 지정' }, + }, + { + kind: 'text', + id: 'txt', + when: (input) => input.mode === 'text', + next: 'place', + defaultValue: '<>', + prompt: (input) => { + const value = measured(input); + return { + text: '치수 문자 입력', + ...(value === null ? {} : { default: { kind: 'enter' as const, label: value } }), + }; + }, + }, + { + kind: 'point', + id: 'place', + prompt: { + text: '치수선의 위치 지정', + options: aligned ? [OPT.TEXT] : [OPT.TEXT, HORIZONTAL, VERTICAL], + absent: aligned ? [MTEXT, ANGLE] : [MTEXT, ANGLE, ROTATED], + }, + options: { + TEXT: { mode: 'text', next: 'txt' }, + ...(aligned + ? {} + : { + HORIZONTAL: { + run: () => { + fixed = 'H'; + }, + }, + VERTICAL: { + run: () => { + fixed = 'V'; + }, + }, + }), + }, + }, + ], + preview: (input) => { + if (input.has('place') || !originsOf(input)) return []; + const span = measure(input, input.cursor); + if (!span) return []; + const ghost = new MeasurementEntity(getActiveLayerId(), span[0], span[1], input.cursor); + if (input.has('txt')) ghost.textOverride = input.get('txt'); + return [ghost]; + }, + commit: (input) => { + const offset = input.get('place'); + const span = measure(input, offset); + fixed = null; + if (!span) { + toast.warn('치수를 넣을 수 없는 객체입니다.'); + return; + } + const dimension = makeDimension( + span[0], + span[1], + offset, + input.has('txt') ? input.get('txt') : undefined + ); + addEntities([dimension], true); + logLine(`치수 문자 = ${dimension.getLabel()}`); + }, + onCancel: () => { + fixed = null; + }, + }); +} + +export const dimLinearToolStateMachine = linearSteps(Tool.DIMLINEAR, false); +export const dimAlignedToolStateMachine = linearSteps(Tool.DIMALIGNED, true); + +/** 기준선 치수는 같은 시작점에서 재고, 치수선을 한 칸 더 띄운다 */ +function baselineDimension(previous: MeasurementEntity, point: Point): MeasurementEntity { + const start = previous.getStartPoint(); + const offset = previous.getOffsetPoint(); + const spacing = offset.distanceTo(start)[0] * 0.35 || 10; + const direction = new Point(offset.x - start.x, offset.y - start.y); + const length = Math.hypot(direction.x, direction.y) || 1; + const nextOffset = new Point( + offset.x + (direction.x / length) * spacing, + offset.y + (direction.y / length) * spacing + ); + return makeDimension(start, point, nextOffset); +} + +const continueDimension = (previous: MeasurementEntity, point: Point): MeasurementEntity => + makeDimension(previous.getEndPoint(), point, previous.getOffsetPoint()); + +interface ChainContext extends ToolContext { + /** 명령 시작 때 직전 치수 · 이 명령이 만든 치수(U 가 뒤에서 뺌) */ + first: MeasurementEntity | null; + made: MeasurementEntity[]; +} +type ChainCtx = { context: ChainContext; event: StateEvent }; + +const CHAIN_PROMPT: PromptSpec = { + text: '두 번째 치수보조선 원점 지정', + options: [OPT.UNDO], + absent: [SELECT], +}; + +/** DIMCONTINUE · DIMBASELINE — 앞 치수에 이어 원점 되풀이 · U = 이 명령 안 마지막 치수 · 빈 Enter · Esc = 끝 */ +function chainTool( + tool: Tool, + build: (previous: MeasurementEntity, point: Point) => MeasurementEntity +) { + const base = (context: ChainContext) => context.made[context.made.length - 1] ?? context.first; + const hasBase = ({ context }: ChainCtx) => !!base(context); + const place = { + guard: hasBase, + actions: assign(({ context, event }: ChainCtx) => { + const previous = base(context) as MeasurementEntity; + const point = getPointFromEvent(previous.getEndPoint(), event as PointInputEvent); + const dimension = build(previous, point); + addEntities([dimension], true); + logLine(`치수 문자 = ${dimension.getLabel()}`); + setAngleGuideOriginPoint(dimension.getEndPoint()); + return { made: [...context.made, dimension] }; + }), + }; + const noBase = { + target: END_STATE, + actions: () => notice('먼저 치수를 하나 작성하십시오.'), + }; + return createMachine({ + types: {} as { context: ChainContext; events: StateEvent }, + context: () => ({ type: tool, first: getLastDimension(), made: [] }), + initial: 'PICK', + states: { + PICK: { + meta: { kind: 'point', prompt: CHAIN_PROMPT }, + entry: ({ context }: ChainCtx) => { + setShouldDrawHelpers(true); + setAngleGuideOriginPoint(base(context)?.getEndPoint() ?? null); + }, + on: { + MOUSE_CLICK: [place, noBase], + ABSOLUTE_POINT_INPUT: [place, noBase], + RELATIVE_POINT_INPUT: [place, noBase], + NUMBER_INPUT: [place, noBase], + 'OPTION.UNDO': { + actions: assign(({ context }: ChainCtx) => { + if (!context.made.length) return {}; + undo(); + const made = context.made.slice(0, -1); + const previous = made[made.length - 1] ?? context.first; + if (previous) rememberDimension(previous); + setAngleGuideOriginPoint(previous?.getEndPoint() ?? null); + return { made }; + }), + }, + ENTER: END_STATE, + ESC: END_STATE, + }, + }, + [END_STATE]: { type: 'final' }, + // biome-ignore lint/suspicious/noExplicitAny: 손으로 짠 상태 — 사건마다 assign 꼴이 달라 풀어 둠(sequence-tool 과 같음) + } as Record, + }); +} + +export const dimBaselineToolStateMachine = chainTool(Tool.DIMBASELINE, baselineDimension); +export const dimContinueToolStateMachine = chainTool(Tool.DIMCONTINUE, continueDimension); export const qDimToolStateMachine = createSequenceTool({ tool: Tool.QDIM, helpers: false, steps: [ - { kind: 'selection', instructions: '치수를 넣을 객체를 선택한 뒤 ENTER.' }, - { kind: 'point', instructions: '치수선 위치를 지정하십시오.' }, + { kind: 'selection', prompt: { ...SELECT_OBJECTS, text: '치수 기입할 형상 선택' } }, + { kind: 'point', prompt: { text: '치수선의 위치 지정' } }, ], commit: (input) => { const offset = input.point(1); @@ -140,8 +347,8 @@ export const dimAutoToolStateMachine = createSequenceTool({ tool: Tool.DIM, helpers: false, steps: [ - { kind: 'entity', instructions: '치수를 넣을 객체를 선택하십시오.' }, - { kind: 'point', instructions: '치수선 위치를 지정하십시오.' }, + { kind: 'entity', prompt: { text: '치수 기입할 객체 선택' } }, + { kind: 'point', prompt: { text: '치수선의 위치 지정' } }, ], commit: (input) => { const entity = input.entity(0); diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts index add2367b7..66a611411 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts @@ -1,9 +1,9 @@ /** 문자 명령 — 여러 줄 문자·단일 행 문자·편집·찾기 (조사표 5절 문자 패널) */ -import { Point } from '@flatten-js/core'; +import { Point, Vector } from '@flatten-js/core'; import { toast } from 'react-toastify'; import { getAnnotationScale } from '../../commands/dim-settings'; +import { type OptionSpec, type PromptSpec, recall } from '../../commands/prompt'; import { EntityName } from '../../entities/Entity'; -import type { Entity } from '../../entities/Entity'; import type { TextEntity } from '../../entities/TextEntity'; import { addEntities, @@ -13,7 +13,7 @@ import { setEntities, } from '../../state'; import { Tool } from '../../tools'; -import { textEntity } from '../factories/entity-factory'; +import { polyLineEntity, textEntity } from '../factories/entity-factory'; import { createSequenceTool } from '../factories/sequence-tool'; /** AutoCAD 여러 줄 문자의 줄바꿈 표기(\P)와 \n을 모두 받는다 */ @@ -24,42 +24,156 @@ const splitLines = (text: string): string[] => .map((line) => line.trim()) .filter((line) => line.length > 0); -function buildTextLines(lines: string[], basePoint: Point): Entity[] { - const lineHeight = getActiveTextStyle().fontSize * getAnnotationScale() * 1.35; - const groupId = lines.length > 1 ? crypto.randomUUID() : undefined; - return lines.map((line, index) => { - const entity = textEntity(line, new Point(basePoint.x, basePoint.y - lineHeight * index), { - textAlign: 'left', - fontSize: getActiveTextStyle().fontSize * getAnnotationScale(), - }); - entity.groupId = groupId; - return entity; - }); -} +/** 줄 간격 = 글자 높이 × 1.35 */ +const LINE_GAP = 1.35; +const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label }); +/** 지금 글자 크기 — TEXTSIZE 처음 값 */ +const textSize = () => getActiveTextStyle().fontSize * getAnnotationScale(); +const TEXT_HEIGHT = (): PromptSpec => ({ + text: '높이 지정', + default: { kind: 'value', remember: 'TEXTSIZE', initial: textSize() }, + number: { positive: true }, +}); +/** 글 물음 — 빈 Enter = 끝(기본은 안 그림) */ +const typing = (text: string): PromptSpec => ({ text, default: { kind: 'option', key: 'DONE' } }); +/** 한 줄 글 — 높이 · 회전 각(도) */ +const placeText = (label: string, point: Point, height: number, angle: number) => { + const radians = (angle * Math.PI) / 180; + return textEntity(label, point, { + textAlign: 'left', + fontSize: height, + textDirection: new Vector(Math.cos(radians), Math.sin(radians)), + }); +}; + +type TextLine = { point: Point; height: number; angle: number; start: Point | null }; +/** TEXT 마지막 줄(세션) — 다음 줄 · 시작점 Enter 가 그 아래로 이어 씀 */ +let lastLine: TextLine | null = null; + +const below = ({ point, height, angle }: TextLine) => { + const radians = (angle * Math.PI) / 180; + const gap = height * LINE_GAP; + return new Point(point.x + Math.sin(radians) * gap, point.y - Math.cos(radians) * gap); +}; + +/** + * TEXT — 「문자의 시작점 지정:」 → 「높이 지정 :」 → 「문자의 회전 각도 지정 <0>:」 → 줄마다 「문자 입력:」. + * 줄 Enter = 그 줄 만들고 아래 새 줄 · 빈 줄 Enter = 끝 · 시작점 Enter = 마지막 글 아래(높이 · 각 안 물음). + */ export const textToolStateMachine = createSequenceTool({ tool: Tool.TEXT, + loopFrom: 'line', steps: [ - { kind: 'point', instructions: '문자의 시작점을 지정하십시오.' }, - { kind: 'text', instructions: '문자를 입력하십시오.' }, + { + kind: 'point', + id: 'start', + prompt: () => ({ + text: '문자의 시작점 지정', + absent: [opt('JUSTIFY', 'J', '자리맞추기'), opt('STYLE', 'S', '스타일')], + ...(lastLine ? { default: { kind: 'option', key: 'CONTINUE' } as const } : {}), + }), + options: { CONTINUE: { mode: 'continue', next: 'line' } }, + }, + { + kind: 'number', + id: 'height', + when: (input) => input.mode !== 'continue', + prompt: TEXT_HEIGHT, + }, + { + kind: 'number', + id: 'angle', + when: (input) => input.mode !== 'continue', + prompt: { + text: '문자의 회전 각도 지정', + default: { kind: 'value', remember: 'TEXT.ANGLE', initial: 0, format: 'ang' }, + }, + }, + { kind: 'text', id: 'line', prompt: typing('문자 입력'), options: { DONE: 'end' } }, ], commit: (input) => { - const lines = splitLines(input.text(1)); - if (!lines.length) return; - addEntities(buildTextLines(lines.slice(0, 1), input.point(0)), true); + const start = input.has('start') ? input.get('start') : null; + const previous = start ? null : lastLine; + const height = previous?.height ?? input.get('height'); + const angle = previous?.angle ?? input.get('angle'); + // 같은 명령의 다음 줄 · 시작점 Enter = 마지막 줄 아래 + const follow = lastLine && (!start || lastLine.start === start) ? lastLine : null; + const point = follow ? below(follow) : (start as Point); + addEntities([placeText(String(input.get('line')), point, height, angle)], true); + lastLine = { point, height, angle, start }; }, }); +/** + * MTEXT — 「첫 번째 구석 지정:」 → 「반대 구석 지정 또는 [높이(H)/폭(W)]:」 → 「문자 입력:」(줄바꿈 \P). + * 첫 줄 = 상자 왼쪽 위 · 줄 감기 없음(줄은 \P 로만). + */ export const mtextToolStateMachine = createSequenceTool({ tool: Tool.MTEXT, steps: [ - { kind: 'point', instructions: '여러 줄 문자의 첫 코너를 지정하십시오.' }, - { kind: 'text', instructions: '문자를 입력하십시오 (줄바꿈은 \\P).' }, + { kind: 'point', id: 'corner', prompt: { text: '첫 번째 구석 지정' } }, + { + kind: 'point', + id: 'opposite', + prompt: { + text: '반대 구석 지정', + options: [opt('HEIGHT', 'H', '높이'), opt('WIDTH', 'W', '폭')], + absent: [ + opt('JUSTIFY', 'J', '자리맞추기'), + opt('LINESPACING', 'L', '행 간격'), + opt('ROTATION', 'R', '회전'), + opt('STYLE', 'S', '스타일'), + opt('COLUMNS', 'C', '열'), + ], + }, + options: { HEIGHT: { mode: 'H', next: 'height' }, WIDTH: { mode: 'W', next: 'width' } }, + }, + { + kind: 'number', + id: 'height', + when: (input) => !input.has('opposite'), + next: 'opposite', + prompt: TEXT_HEIGHT, + }, + { + kind: 'point', + id: 'width', + when: (input) => !input.has('opposite'), + prompt: { text: '폭 지정', number: { positive: true } }, + }, + { + kind: 'text', + id: 'text', + prompt: typing('문자 입력 (줄바꿈 \\P)'), + options: { DONE: 'end' }, + }, ], + preview: (input) => { + const corner = input.get('corner'); + if (!corner || input.has('opposite') || input.mode === 'W') return []; + const { x, y } = input.cursor; + const box = [corner, new Point(x, corner.y), new Point(x, y), new Point(corner.x, y), corner]; + const outline = polyLineEntity(box); + return outline ? [outline] : []; + }, commit: (input) => { - const lines = splitLines(input.text(1)); + const lines = splitLines(String(input.get('text'))); if (!lines.length) return; - addEntities(buildTextLines(lines, input.point(0)), true); + const corner = input.get('corner'); + const opposite = input.has('opposite') ? input.get('opposite') : corner; + const height = input.has('height') + ? input.get('height') + : (recall('TEXTSIZE') ?? textSize()); + const left = Math.min(corner.x, opposite.x); + const top = Math.max(corner.y, opposite.y) - height; + const groupId = lines.length > 1 ? crypto.randomUUID() : undefined; + const entities = lines.map((line, index) => { + const entity = placeText(line, new Point(left, top - height * LINE_GAP * index), height, 0); + entity.groupId = groupId; + return entity; + }); + addEntities(entities, true); }, }); diff --git a/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts b/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts index 8f992f6c9..50d9b6681 100644 --- a/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts @@ -1,172 +1,229 @@ +/** + * 원(CIRCLE) — 337 설계 1-1 · 오토캐드 차례. + * 「원에 대한 중심점 지정 또는 [3점(3P)/2점(2P)]:」 → 「원의 반지름 지정 또는 [지름(D)] :」 · + * D → 「원의 지름 지정 <2r>:」 · 3P → 원 위 세 점 · 2P → 지름 두 끝점. + * 반지름 기억(CIRCLERAD · 처음 없음 → <> 안 그림 · 빈 Enter 아무 일 없음) · 누른 점 · 3P · 2P 반지름도 기억. 하나 그리면 끝. + */ +import { Point } from '@flatten-js/core'; +import { assign, createMachine } from 'xstate'; +import { type PromptSpec, recall, remember } from '../commands/prompt'; import { CircleEntity } from '../entities/CircleEntity'; -import type { Point } from '@flatten-js/core'; +import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; +import { arcThroughThreePoints } from '../helpers/geometry/shape-points'; import { addEntities, getActiveLayerId, - getActiveLineColor, - getActiveLineDash, - getActiveLineWidth, - setActiveToolActor, setAngleGuideOriginPoint, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers, } from '../state'; -import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; import { Tool } from '../tools'; -import { Actor, assign, createMachine } from 'xstate'; -import { pointDistance } from '../helpers/distance-between-points'; -import { LineState } from './line-tool.ts'; -import { selectToolStateMachine } from './select-tool.ts'; -import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; +import { END, firstPointEvents, option, pointEvents } from './draw/draw-steps'; +import { styled } from './factories/entity-factory'; +import { + type DrawEvent, + END_STATE, + type NumberInputEvent, + type PointInputEvent, + type StateEvent, + type ToolContext, +} from './tool.types'; export interface CircleContext extends ToolContext { centerPoint: Point | null; + /** 3P · 2P 에서 이미 찍은 점 */ + points: Point[]; + threePoints: boolean; } export enum CircleState { - WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT', - WAITING_FOR_POINT_ON_CIRCLE = 'WAITING_FOR_POINT_ON_CIRCLE', INIT = 'INIT', + WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT', + WAITING_FOR_RADIUS = 'WAITING_FOR_RADIUS', + WAITING_FOR_DIAMETER = 'WAITING_FOR_DIAMETER', + THREE_POINTS = 'THREE_POINTS', + TWO_POINTS = 'TWO_POINTS', } -export enum CircleAction { - INIT_CIRCLE_TOOL = 'INIT_CIRCLE_TOOL', - RECORD_START_POINT = 'RECORD_START_POINT', - DRAW_TEMP_CIRCLE = 'DRAW_TEMP_CIRCLE', - DRAW_FINAL_CIRCLE = 'DRAW_FINAL_CIRCLE', - SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL', +const RADIUS_KEY = 'CIRCLERAD'; +/** 지름 기본 = 2 × 기억 반지름 — 이 열쇠엔 쓰지 않음 */ +const DIAMETER_KEY = 'CIRCLERAD.D'; + +const CENTER_PROMPT: PromptSpec = { + text: '원에 대한 중심점 지정', + options: [option('3P', '3P', '3점'), option('2P', '2P', '2점')], + absent: [option('TTR', 'T', 'Ttr - 접선 접선 반지름')], +}; +const RADIUS_PROMPT: PromptSpec = { + text: '원의 반지름 지정', + options: [option('DIAMETER', 'D', '지름')], + default: { kind: 'value', remember: RADIUS_KEY, initial: null }, + number: { positive: true }, +}; +const diameterPrompt = (): PromptSpec => { + const radius = recall(RADIUS_KEY); + return { + text: '원의 지름 지정', + default: { kind: 'value', remember: DIAMETER_KEY, initial: radius ? radius * 2 : null }, + number: { positive: true }, + }; +}; +const ORDINAL = ['첫 번째', '두 번째', '세 번째']; +const threePrompt = (context: Partial): PromptSpec => ({ + text: `원 위의 ${ORDINAL[context.points?.length ?? 0]} 점 지정`, +}); +const twoPrompt = (context: Partial): PromptSpec => ({ + text: `원 지름의 ${ORDINAL[context.points?.length ?? 0]} 끝점 지정`, +}); + +function addCircle(center: Point, radius: number) { + setGhostHelperEntities([]); + if (!(radius > 0)) return; + addEntities([styled(new CircleEntity(getActiveLayerId(), center, radius))], true); + remember(RADIUS_KEY, radius); } +/** 3P · 2P — 찍은 점들 + 마지막 점으로 원(점 모자람 · 한 줄 = null) */ +function circleThrough(context: CircleContext, last: Point) { + const [a, b] = context.points; + if (!context.threePoints) { + return a + ? { + center: new Point((a.x + last.x) / 2, (a.y + last.y) / 2), + radius: a.distanceTo(last)[0] / 2, + } + : null; + } + const arc = b ? arcThroughThreePoints(a, b, last) : null; + return arc ? { center: arc.center, radius: arc.radius } : null; +} + +/** 점 사건 → 점(앞 점 기준 · 첫 점이면 기준 없음) */ +const pointAt = (base: Point | null, event: StateEvent) => + getPointFromEvent(base, event as PointInputEvent); +const lastOf = (points: Point[]) => points[points.length - 1] ?? null; + +/** 3P 는 세 점 · 2P 는 두 점째에 원 */ +const pointsEvents = (count: number, self: CircleState) => { + const enough = ({ context }: { context: CircleContext }) => context.points.length === count - 1; + const done = { guard: enough, actions: 'drawThrough', target: END_STATE }; + const more = { actions: 'recordPoint', target: self }; + return { + DRAW: { actions: 'previewThrough' }, + MOUSE_CLICK: [done, more], + ABSOLUTE_POINT_INPUT: [done, more], + RELATIVE_POINT_INPUT: [done, more], + NUMBER_INPUT: [done, more], + ESC: { target: END_STATE }, + }; +}; + export const circleToolStateMachine = createMachine( { - types: {} as { - context: CircleContext; - events: StateEvent; - }, - context: { - centerPoint: null, - type: Tool.CIRCLE, - }, + types: {} as { context: CircleContext; events: StateEvent }, + context: { centerPoint: null, points: [], threePoints: false, type: Tool.CIRCLE }, initial: CircleState.INIT, states: { [CircleState.INIT]: { - description: 'Initializing the circle tool', - always: { - actions: CircleAction.INIT_CIRCLE_TOOL, - target: CircleState.WAITING_FOR_CENTER_POINT, - }, + always: { actions: 'init', target: CircleState.WAITING_FOR_CENTER_POINT }, }, [CircleState.WAITING_FOR_CENTER_POINT]: { - description: 'Select the center point of the circle tool', - meta: { - instructions: '중심점 지정 (x,y):', - }, + meta: { kind: 'point', prompt: CENTER_PROMPT }, on: { - MOUSE_CLICK: { - actions: CircleAction.RECORD_START_POINT, - target: CircleState.WAITING_FOR_POINT_ON_CIRCLE, - }, - ABSOLUTE_POINT_INPUT: { - actions: CircleAction.RECORD_START_POINT, - target: CircleState.WAITING_FOR_POINT_ON_CIRCLE, - }, - ESC: { - actions: CircleAction.SWITCH_TO_SELECT_TOOL, - }, + ...firstPointEvents('recordCenter', CircleState.WAITING_FOR_RADIUS), + 'OPTION.3P': { actions: 'pickThree', target: CircleState.THREE_POINTS }, + 'OPTION.2P': { actions: 'pickTwo', target: CircleState.TWO_POINTS }, + ESC: { target: END_STATE }, }, }, - [CircleState.WAITING_FOR_POINT_ON_CIRCLE]: { - description: 'Select a point on the circle', - meta: { - instructions: '원 위의 점 지정 또는 반지름 입력:', - }, + [CircleState.WAITING_FOR_RADIUS]: { + meta: { kind: 'point', prompt: RADIUS_PROMPT }, on: { - DRAW: { - actions: CircleAction.DRAW_TEMP_CIRCLE, - }, - MOUSE_CLICK: { - actions: CircleAction.DRAW_FINAL_CIRCLE, - target: CircleState.INIT, - }, - NUMBER_INPUT: { - actions: CircleAction.DRAW_FINAL_CIRCLE, - target: LineState.INIT, - }, - ABSOLUTE_POINT_INPUT: { - actions: CircleAction.DRAW_FINAL_CIRCLE, - target: LineState.INIT, - }, - RELATIVE_POINT_INPUT: { - actions: CircleAction.DRAW_FINAL_CIRCLE, - target: LineState.INIT, - }, - ESC: { - target: CircleState.INIT, - }, + DRAW: { actions: 'previewRadius' }, + ...pointEvents('drawByRadius', END_STATE), + 'OPTION.DIAMETER': { target: CircleState.WAITING_FOR_DIAMETER }, + ESC: { target: END_STATE }, }, }, + [CircleState.WAITING_FOR_DIAMETER]: { + meta: { kind: 'point', prompt: diameterPrompt }, + on: { + DRAW: { actions: 'previewDiameter' }, + ...pointEvents('drawByDiameter', END_STATE), + ESC: { target: END_STATE }, + }, + }, + [CircleState.THREE_POINTS]: { + meta: { kind: 'point', prompt: threePrompt }, + on: pointsEvents(3, CircleState.THREE_POINTS), + }, + [CircleState.TWO_POINTS]: { + meta: { kind: 'point', prompt: twoPrompt }, + on: pointsEvents(2, CircleState.TWO_POINTS), + }, + ...END, }, }, { actions: { - [CircleAction.INIT_CIRCLE_TOOL]: assign(() => { + init: assign(() => { setShouldDrawHelpers(true); setGhostHelperEntities([]); setSelectedEntityIds([]); setAngleGuideOriginPoint(null); - return { - centerPoint: null, - }; + return { centerPoint: null, points: [], threePoints: false }; }), - [CircleAction.RECORD_START_POINT]: assign(({ event }) => { - const startPoint = getPointFromEvent(null, event as PointInputEvent); - setAngleGuideOriginPoint(startPoint); - return { - centerPoint: startPoint, - }; + pickThree: assign({ threePoints: true }), + pickTwo: assign({ threePoints: false }), + recordCenter: assign(({ event }) => { + const centerPoint = pointAt(null, event); + setAngleGuideOriginPoint(centerPoint); + return { centerPoint }; }), - [CircleAction.DRAW_TEMP_CIRCLE]: ({ context, event }) => { - const activeCircle = new CircleEntity( - getActiveLayerId(), - context.centerPoint as Point, - pointDistance( - (event as DrawEvent).drawController.getWorldMouseLocation(), - context.centerPoint as Point - ) - ); - activeCircle.lineColor = getActiveLineColor(); - activeCircle.lineWidth = getActiveLineWidth(); - activeCircle.lineDash = getActiveLineDash(); - setGhostHelperEntities([activeCircle]); + previewRadius: ({ context, event }) => { + const center = context.centerPoint as Point; + const radius = center.distanceTo(getPointFromEvent(center, event as DrawEvent))[0]; + setGhostHelperEntities([styled(new CircleEntity(getActiveLayerId(), center, radius))]); }, - [CircleAction.DRAW_FINAL_CIRCLE]: assign(({ context, event }) => { - if (!context.centerPoint) { - throw new Error( - 'Trying to DRAW_FINAL_CIRCLE when centerPoint is not yet defined in circle tool' - ); - } - const pointOnCircle: Point = getPointFromEvent( - context.centerPoint, - event as PointInputEvent - ); - const activeCircle = new CircleEntity( - getActiveLayerId(), - context.centerPoint as Point, - pointDistance(pointOnCircle, context.centerPoint as Point) - ); - activeCircle.lineColor = getActiveLineColor(); - activeCircle.lineWidth = getActiveLineWidth(); - activeCircle.lineDash = getActiveLineDash(); - addEntities([activeCircle], true); - - setGhostHelperEntities([]); - return { - centerPoint: null, - }; + previewDiameter: ({ context, event }) => { + const center = context.centerPoint as Point; + const radius = center.distanceTo(getPointFromEvent(center, event as DrawEvent))[0] / 2; + setGhostHelperEntities([styled(new CircleEntity(getActiveLayerId(), center, radius))]); + }, + drawByRadius: ({ context, event }) => { + const center = context.centerPoint as Point; + const radius = + event.type === 'NUMBER_INPUT' + ? (event as NumberInputEvent).value + : center.distanceTo(pointAt(center, event))[0]; + addCircle(center, radius); + }, + drawByDiameter: ({ context, event }) => { + const center = context.centerPoint as Point; + const diameter = + event.type === 'NUMBER_INPUT' + ? (event as NumberInputEvent).value + : center.distanceTo(pointAt(center, event))[0]; + addCircle(center, diameter / 2); + }, + recordPoint: assign(({ context, event }) => { + const point = pointAt(lastOf(context.points), event); + setAngleGuideOriginPoint(point); + return { points: [...context.points, point] }; }), - [CircleAction.SWITCH_TO_SELECT_TOOL]: () => { - setActiveToolActor(new Actor(selectToolStateMachine)); + previewThrough: ({ context, event }) => { + const last = lastOf(context.points); + if (!last) return; + const circle = circleThrough(context, getPointFromEvent(last, event as DrawEvent)); + setGhostHelperEntities( + circle ? [styled(new CircleEntity(getActiveLayerId(), circle.center, circle.radius))] : [] + ); + }, + drawThrough: ({ context, event }) => { + const circle = circleThrough(context, pointAt(lastOf(context.points), event)); + if (circle) addCircle(circle.center, circle.radius); + else setGhostHelperEntities([]); }, }, } diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/arc-tool.ts b/B07_DesignDetail/openwebcad/src/tools/draw/arc-tool.ts new file mode 100644 index 000000000..3ce1f17d9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/draw/arc-tool.ts @@ -0,0 +1,126 @@ +/** + * 호(ARC) — 337 설계 1-1 · 오토캐드 차례(순서 엔진 v2). + * 「호의 시작점 지정:」 → 「호의 두 번째 점 지정 또는 [중심(C)/끝(E)]:」 → 「호의 끝점 지정:」(세 점) · + * C → 「호의 중심점 지정:」 → 「호의 끝점 지정 또는 [각도(A)]:」(A → 「사이각 지정:」) · + * E → 「호의 끝점 지정:」 → 「호의 중심점 지정 또는 [반지름(R)]:」(R → 「호의 반지름 지정:」) · + * 중심 · 끝 · 반지름 방식은 시작점에서 반시계(오토캐드) · 사이각 − = 시계 · 반지름 = 작은 호. 하나 그리면 끝. + */ +import { Point } from '@flatten-js/core'; +import type { Entity } from '../../entities/Entity'; +import { type ArcDefinition, arcThroughThreePoints } from '../../helpers/geometry/shape-points'; +import { addEntities } from '../../state'; +import { Tool } from '../../tools'; +import { arcEntity, lineEntity } from '../factories/entity-factory'; +import { type SequenceInput, createSequenceTool } from '../factories/sequence-tool'; +import { option, rememberDrawEnd } from './draw-steps'; + +const angleOf = (center: Point, point: Point) => Math.atan2(point.y - center.y, point.x - center.x); + +/** 중심 · 시작점 · 끝각 — 반시계(사이각 − 면 시계) */ +const arcAround = (center: Point, start: Point, endAngle: number, ccw = true): ArcDefinition => ({ + center, + radius: center.distanceTo(start)[0], + startAngle: angleOf(center, start), + endAngle, + counterClockwise: ccw, +}); + +/** 시작 · 끝 · 반지름 — 반시계 작은 호 · 반지름이 현의 반보다 작으면 null */ +function arcByRadius(start: Point, end: Point, radius: number): ArcDefinition | null { + const half = start.distanceTo(end)[0] / 2; + if (half === 0 || radius < half) return null; + const rise = Math.sqrt(radius * radius - half * half); + const ux = (end.x - start.x) / (2 * half); + const uy = (end.y - start.y) / (2 * half); + const center = new Point((start.x + end.x) / 2 - uy * rise, (start.y + end.y) / 2 + ux * rise); + return arcAround(center, start, angleOf(center, end)); +} + +/** 지금 값으로 호 — 커서(미리보기)가 마지막 빈 값을 채움 */ +function arcOf(input: SequenceInput, cursor?: Point): ArcDefinition | null { + const start = input.get('start'); + const at = (id: string) => (input.has(id) ? input.get(id) : cursor); + if (input.mode === 'C' || input.mode === 'CA') { + const center = input.get('center'); + if (input.mode === 'CA') { + const sweep = input.has('angle') ? input.get('angle') : null; + if (sweep === null) return null; + return arcAround(center, start, angleOf(center, start) + (sweep * Math.PI) / 180, sweep > 0); + } + const end = at('cEnd'); + return end ? arcAround(center, start, angleOf(center, end)) : null; + } + if (input.mode === 'E' || input.mode === 'ER') { + const end = input.get('eEnd'); + if (input.mode === 'ER') { + const radius = at('radius'); + return radius ? arcByRadius(start, end, end.distanceTo(radius)[0]) : null; + } + const center = at('eCenter'); + return center ? arcAround(center, start, angleOf(center, end)) : null; + } + const third = at('third'); + return third ? arcThroughThreePoints(start, input.get('second'), third) : null; +} + +const POINT_TEXT = '호의 끝점 지정'; +const when = + (...modes: (string | null)[]) => + (input: SequenceInput) => + modes.includes(input.mode); + +export const arcToolStateMachine = createSequenceTool({ + tool: Tool.ARC, + steps: [ + { kind: 'point', id: 'start', prompt: { text: '호의 시작점 지정' } }, + { + kind: 'point', + id: 'second', + prompt: { + text: '호의 두 번째 점 지정', + options: [option('CENTER', 'C', '중심'), option('END', 'E', '끝')], + }, + options: { CENTER: { mode: 'C', next: 'center' }, END: { mode: 'E', next: 'eEnd' } }, + }, + { kind: 'point', id: 'third', prompt: { text: POINT_TEXT }, when: when(null) }, + { kind: 'point', id: 'center', prompt: { text: '호의 중심점 지정' }, when: when('C', 'CA') }, + { + kind: 'point', + id: 'cEnd', + prompt: { text: POINT_TEXT, options: [option('ANGLE', 'A', '각도')] }, + options: { ANGLE: { mode: 'CA', next: 'angle' } }, + when: when('C'), + }, + { kind: 'number', id: 'angle', prompt: { text: '사이각 지정' }, when: when('CA') }, + { kind: 'point', id: 'eEnd', prompt: { text: POINT_TEXT }, when: when('E', 'ER') }, + { + kind: 'point', + id: 'eCenter', + prompt: { text: '호의 중심점 지정', options: [option('RADIUS', 'R', '반지름')] }, + options: { RADIUS: { mode: 'ER', next: 'radius' } }, + when: when('E'), + }, + { kind: 'point', id: 'radius', prompt: { text: '호의 반지름 지정' }, when: when('ER') }, + ], + preview: (input) => { + const arc = input.values.length > 1 ? arcOf(input, input.cursor) : null; + const shown: Entity[] = arc ? [arcEntity(arc)] : []; + const last = input.points()[input.points().length - 1]; + return arc || !last ? shown : [lineEntity(last, input.cursor)]; + }, + commit: (input) => { + const arc = arcOf(input); + if (!arc) { + // 세 점 한 줄 = 선(오토캐드는 거절 — 버리지 않게 선으로) + if (input.mode === null) { + addEntities([lineEntity(input.get('start'), input.get('third'))], true); + } + return; + } + addEntities([arcEntity(arc)], true); + rememberDrawEnd({ + x: arc.center.x + arc.radius * Math.cos(arc.endAngle), + y: arc.center.y + arc.radius * Math.sin(arc.endAngle), + }); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts index 5e052f815..43eb3d126 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts @@ -1,114 +1,65 @@ -/** 그리기 명령 — 폴리선·호·다각형·타원·스플라인·점 (조사표 1절) */ +/** 그리기 명령 — 스플라인·점·도넛 + 폴리선·호·다각형·타원 다시 내보냄 (조사표 1절 · 337 설계 1-1) */ import { CircleEntity } from '../../entities/CircleEntity'; import type { Entity } from '../../entities/Entity'; -import { - arcThroughThreePoints, - ellipsePoints, - regularPolygonPoints, - splinePoints, -} from '../../helpers/geometry/shape-points'; +import type { Point } from '@flatten-js/core'; +import { OPT } from '../../commands/prompt'; +import { splinePoints } from '../../helpers/geometry/shape-points'; import { addEntities, getActiveLayerId } from '../../state'; import { Tool } from '../../tools'; -import { - arcEntity, - lineEntity, - pointEntity, - polyLineEntity, - styled, -} from '../factories/entity-factory'; +import { pointEntity, polyLineEntity, styled } from '../factories/entity-factory'; import { createSequenceTool } from '../factories/sequence-tool'; +import { option } from './draw-steps'; -export const plineToolStateMachine = createSequenceTool({ - tool: Tool.PLINE, - steps: [ - { kind: 'point', instructions: '폴리선의 시작점을 지정하십시오.' }, - { kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' }, - ], - repeatLastStep: true, - preview: (input) => { - const preview = polyLineEntity([...input.points(), input.cursor]); - return preview ? [preview] : []; - }, - commit: (input) => { - const polyline = polyLineEntity(input.points()); - if (polyline) addEntities([polyline], true); - }, -}); +export { arcToolStateMachine } from './arc-tool'; +export { ellipseToolStateMachine } from './ellipse-tool'; +export { plineToolStateMachine } from './pline-tool'; +export { polygonToolStateMachine } from './polygon-tool'; -export const arcToolStateMachine = createSequenceTool({ - tool: Tool.ARC, - steps: [ - { kind: 'point', instructions: '호의 시작점을 지정하십시오.' }, - { kind: 'point', instructions: '호가 지나갈 두 번째 점을 지정하십시오.' }, - { kind: 'point', instructions: '호의 끝점을 지정하십시오.' }, - ], - preview: (input) => { - const points = input.points(); - if (points.length === 1) return [lineEntity(points[0], input.cursor)]; - if (points.length === 2) { - const definition = arcThroughThreePoints(points[0], points[1], input.cursor); - return definition ? [arcEntity(definition)] : [lineEntity(points[0], input.cursor)]; - } - return []; - }, - commit: (input) => { - const [start, through, end] = input.points(); - const definition = arcThroughThreePoints(start, through, end); - // 세 점이 일직선이면 호가 성립하지 않으므로 선으로 대체한다 - addEntities([definition ? arcEntity(definition) : lineEntity(start, end)], true); - }, -}); +/** 닫힌 스플라인 — 끝 둘레 이웃을 이어 붙여 그린 뒤 이음 구간만 남김 */ +function closedSplinePoints(points: Point[]): Point[] { + const spans = 12; + const wrapped = splinePoints([points[points.length - 1], ...points, points[0], points[1]], spans); + const loop = wrapped.slice(spans, spans * (points.length + 1)); + return [...loop, loop[0]]; +} -export const polygonToolStateMachine = createSequenceTool({ - tool: Tool.POLYGON, - steps: [ - { kind: 'number', instructions: '면의 수를 입력하십시오 <6>.', defaultValue: 6 }, - { kind: 'point', instructions: '다각형의 중심을 지정하십시오.' }, - { kind: 'point', instructions: '꼭짓점을 지정하십시오 (중심에서의 반지름).' }, - ], - preview: (input) => { - const points = input.points(); - if (points.length !== 1) return []; - const polygon = polyLineEntity(regularPolygonPoints(points[0], input.cursor, input.number(0))); - return polygon ? [polygon] : []; - }, - commit: (input) => { - const [center, vertex] = input.points(); - const polygon = polyLineEntity(regularPolygonPoints(center, vertex, input.number(0))); - if (polygon) addEntities([polygon], true); - }, -}); - -export const ellipseToolStateMachine = createSequenceTool({ - tool: Tool.ELLIPSE, - steps: [ - { kind: 'point', instructions: '타원의 중심을 지정하십시오.' }, - { kind: 'point', instructions: '장축의 끝점을 지정하십시오.' }, - { kind: 'point', instructions: '단축 거리를 지정하십시오.' }, - ], - preview: (input) => { - const points = input.points(); - if (points.length === 1) return [lineEntity(points[0], input.cursor)]; - if (points.length === 2) { - const minor = points[0].distanceTo(input.cursor)[0]; - const ellipse = polyLineEntity(ellipsePoints(points[0], points[1], minor)); - return ellipse ? [ellipse] : []; - } - return []; - }, - commit: (input) => { - const [center, majorPoint, minorPoint] = input.points(); - const minorRadius = center.distanceTo(minorPoint)[0]; - const ellipse = polyLineEntity(ellipsePoints(center, majorPoint, minorRadius)); - if (ellipse) addEntities([ellipse], true); - }, -}); +const NEXT_POINT = '다음 점 입력'; +const TOLERANCE = option('TOLERANCE', 'L', '공차'); +/** + * 스플라인(SPLINE) — 337 설계 1-1. 「첫 번째 점 지정:」 → 「다음 점 입력:」 → 「다음 점 입력 또는 [명령 취소(U)]:」 → + * 넷째 점부터 [명령 취소(U)/닫기(C)] 되풀이 · 빈 Enter = 확정 · C = 닫고 끝 · Esc = 버림. + */ export const splineToolStateMachine = createSequenceTool({ tool: Tool.SPLINE, steps: [ - { kind: 'point', instructions: '스플라인의 첫 점을 지정하십시오.' }, - { kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' }, + { + kind: 'point', + prompt: { + text: '첫 번째 점 지정', + absent: [ + option('METHOD', 'M', '방법'), + option('KNOTS', 'K', '매듭'), + option('OBJECT', 'O', '객체'), + ], + }, + }, + { + kind: 'point', + prompt: { + text: NEXT_POINT, + absent: [option('TANGENT', 'T', '시작 접촉부'), TOLERANCE], + }, + }, + { + kind: 'point', + prompt: (input) => ({ + text: NEXT_POINT, + options: input.points().length >= 3 ? [OPT.UNDO, OPT.CLOSE] : [OPT.UNDO], + absent: [option('TANGENT', 'T', '끝 접촉부'), TOLERANCE], + }), + options: { UNDO: 'undo', CLOSE: 'close' }, + }, ], repeatLastStep: true, preview: (input) => { @@ -116,7 +67,8 @@ export const splineToolStateMachine = createSequenceTool({ return preview ? [preview] : []; }, commit: (input) => { - const spline = polyLineEntity(splinePoints(input.points())); + const points = input.points(); + const spline = polyLineEntity(input.closed ? closedSplinePoints(points) : splinePoints(points)); if (spline) addEntities([spline], true); }, }); @@ -138,6 +90,7 @@ export const donutToolStateMachine = createSequenceTool({ { kind: 'number', instructions: '도넛의 외부 지름을 입력하십시오 <2>.', defaultValue: 2 }, { kind: 'point', instructions: '도넛의 중심을 지정하십시오.' }, ], + loopFrom: 2, // 오토캐드 — 중심 되풀이 · 빈 Enter 끝 preview: (input) => donutEntities(input.number(0), input.number(1), input.cursor), commit: (input) => { addEntities(donutEntities(input.number(0), input.number(1), input.point(2)), true); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts index 01209cceb..3520c438e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts @@ -3,11 +3,12 @@ import { Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; import type { Entity } from '../../entities/Entity'; import { getMlineElements, getMlineSpacing, setMlineStyle } from '../../commands/draw-settings'; +import type { OptionSpec, PromptSpec } from '../../commands/prompt'; import { offsetPolylinePoints } from '../../helpers/geometry/shape-points'; import { addEntities, getScreenCanvasDrawController } from '../../state'; import { Tool } from '../../tools'; import { hatchEntity, lineEntity, polyLineEntity } from '../factories/entity-factory'; -import { createSequenceTool } from '../factories/sequence-tool'; +import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool'; /** * 구성선은 원래 무한하지만, 무한 선은 경계상자를 망가뜨려 [범위 줌]을 못 쓰게 만든다. @@ -32,30 +33,75 @@ function extendFrom(base: Point, through: Point, bothWays: boolean): [Point, Poi return [backward, forward]; } +const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label }); + +/** 통과점 물음 — 빈 Enter = 끝(기본은 안 그림) */ +const THROUGH: PromptSpec = { text: '통과점을 지정', default: { kind: 'option', key: 'DONE' } }; + +/** 구성선 양 끝 — 수평 · 수직 · 각도 모드는 통과점 하나로 · 아니면 첫 점 → 통과점 방향 */ +function xlineEnds(input: SequenceInput, through: Point): [Point, Point] | null { + const degrees = { H: 0, V: 90, A: input.has('angle') ? input.get('angle') : 0 }[ + input.mode ?? '' + ]; + if (degrees === undefined) { + const base = input.get('base'); + if (!base || (base.x === through.x && base.y === through.y)) return null; + return extendFrom(base, through, true); + } + const radians = (degrees * Math.PI) / 180; + const toward = new Point(through.x + Math.cos(radians), through.y + Math.sin(radians)); + return extendFrom(through, toward, true); +} + +/** XLINE — 오토캐드 「점 지정 또는 [수평(H)/수직(V)/각도(A)]:」 → 「통과점을 지정:」 되풀이 */ export const xlineToolStateMachine = createSequenceTool({ tool: Tool.XLINE, + loopFrom: 'through', steps: [ - { kind: 'point', instructions: '구성선이 지날 점을 지정하십시오.' }, - { kind: 'point', instructions: '방향을 지정하십시오.' }, + { + kind: 'point', + id: 'base', + prompt: { + text: '점 지정', + options: [opt('HOR', 'H', '수평'), opt('VER', 'V', '수직'), opt('ANG', 'A', '각도')], + absent: [opt('BISECT', 'B', '이등분'), opt('OFFSET', 'O', '간격띄우기')], + }, + options: { + HOR: { mode: 'H', next: 'through' }, + VER: { mode: 'V', next: 'through' }, + ANG: { mode: 'A', next: 'angle' }, + }, + }, + { + kind: 'number', + id: 'angle', + when: (input) => input.mode === 'A', + prompt: { + text: 'X선의 각도 입력', + default: { kind: 'value', remember: 'XLINE.ANGLE', initial: 0, format: 'ang' }, + absent: [opt('REFERENCE', 'R', '참조')], + }, + }, + { kind: 'point', id: 'through', prompt: THROUGH, options: { DONE: 'end' } }, ], preview: (input) => { - const points = input.points(); - if (points.length !== 1) return []; - const [start, end] = extendFrom(points[0], input.cursor, true); - return [lineEntity(start, end)]; + if (!input.mode && !input.has('base')) return []; + const ends = xlineEnds(input, input.cursor); + return ends ? [lineEntity(...ends)] : []; }, commit: (input) => { - const [base, through] = input.points(); - const [start, end] = extendFrom(base, through, true); - addEntities([lineEntity(start, end)], true); + const ends = xlineEnds(input, input.get('through')); + if (ends) addEntities([lineEntity(...ends)], true); }, }); +/** RAY — 「시작점을 지정:」 → 「통과점을 지정:」 되풀이 */ export const rayToolStateMachine = createSequenceTool({ tool: Tool.RAY, + loopFrom: 'through', steps: [ - { kind: 'point', instructions: '광선의 시작점을 지정하십시오.' }, - { kind: 'point', instructions: '통과점을 지정하십시오.' }, + { kind: 'point', id: 'base', prompt: { text: '시작점을 지정' } }, + { kind: 'point', id: 'through', prompt: THROUGH, options: { DONE: 'end' } }, ], preview: (input) => { const points = input.points(); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts index 19ddd7c3c..8cd40d337 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts @@ -1,5 +1,9 @@ -/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */ +/** + * 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다. + * 오토캐드 물음 차례(337 설계 1-2) · 기본값 없음 · 한 번 뒤 끝 · 블록(B) 없음. + */ import { toast } from 'react-toastify'; +import type { OptionSpec } from '../../commands/prompt'; import { dividePoints, measurePoints, @@ -10,12 +14,21 @@ import { Tool } from '../../tools'; import { pointEntity } from '../factories/entity-factory'; import { createSequenceTool } from '../factories/sequence-tool'; +const BLOCK: OptionSpec = { key: 'BLOCK', kw: 'B', en: 'BLOCK', label: '블록' }; + export const divideToolStateMachine = createSequenceTool({ tool: Tool.DIVIDE, helpers: false, steps: [ - { kind: 'entity', instructions: '등분할 객체를 선택하십시오.' }, - { kind: 'number', instructions: '세그먼트 수를 입력하십시오 <4>.', defaultValue: 4 }, + { kind: 'entity', prompt: { text: '등분할 객체 선택' } }, + { + kind: 'number', + prompt: { + text: '세그먼트 수 입력', + absent: [BLOCK], + number: { int: true, min: 2, max: 32767 }, + }, + }, ], commit: (input) => { const points = dividePoints(sampleEntityPoints(input.entity(0)), input.number(1)); @@ -31,11 +44,19 @@ export const measureLengthToolStateMachine = createSequenceTool({ tool: Tool.MEASURE_LENGTH, helpers: false, steps: [ - { kind: 'entity', instructions: '길이로 분할할 객체를 선택하십시오.' }, - { kind: 'number', instructions: '세그먼트 길이를 입력하십시오 <10>.', defaultValue: 10 }, + { kind: 'entity', prompt: { text: '길이 분할 객체 선택' } }, + { + kind: 'number', + prompt: { text: '세그먼트의 길이 지정', absent: [BLOCK], number: { positive: true } }, + }, ], commit: (input) => { - const points = measurePoints(sampleEntityPoints(input.entity(0)), input.number(1)); + // 누른 자리에 가까운 끝부터 잼(오토캐드) + const path = sampleEntityPoints(input.entity(0)); + const pick = input.pick(0); + const nearEnd = + path.length > 1 && path[path.length - 1].distanceTo(pick)[0] < path[0].distanceTo(pick)[0]; + const points = measurePoints(nearEnd ? [...path].reverse() : path, input.number(1)); if (!points.length) { toast.warn('지정한 길이로 나눌 수 없습니다.'); return; diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/draw-steps.ts b/B07_DesignDetail/openwebcad/src/tools/draw/draw-steps.ts new file mode 100644 index 000000000..4a7cbb7e2 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/draw/draw-steps.ts @@ -0,0 +1,39 @@ +/** + * 손으로 짠 그리기 도구 공용(337 설계 2-14) — 점 물음 사건 · 옵션 한 줄 · 끝 상태 · 마지막 그린 끝점(LINE.END). + * 선 · 폴리선 · 원 · 사각형 · 다각형 · 타원이 같이 씀. + */ +import { Point } from '@flatten-js/core'; +import { type OptionSpec, type XY, recall, remember } from '../../commands/prompt'; +import { END_STATE } from '../tool.types'; + +/** 옵션 하나 — 그림 「label(kw)」 · 사건 `OPTION.` · 맞추기 kw · en */ +export const option = (key: string, kw: string, label: string, en = key): OptionSpec => ({ + key, + kw, + en, + label, +}); + +/** 첫 점 물음(기준점 없음) — 누름 · 절대 좌표만 */ +export const firstPointEvents = (actions: string | string[], target: string) => ({ + MOUSE_CLICK: { actions, target }, + ABSOLUTE_POINT_INPUT: { actions, target }, +}); + +/** 기준점 있는 점 물음 — 누름 · 절대 · 상대 · 거리 숫자 */ +export const pointEvents = (actions: string | string[], target: string) => ({ + ...firstPointEvents(actions, target), + RELATIVE_POINT_INPUT: { actions, target }, + NUMBER_INPUT: { actions, target }, +}); + +/** 명령 끝 상태 — 들면 쉬는 상태(선택 도구)로 */ +export const END = { [END_STATE]: { type: 'final' as const } }; + +// 마지막 선 · 폴리선 · 호 끝점 — LINE · PLINE 첫 점 빈 Enter 가 여기서 이어 그림(오토캐드) +const LINE_END = 'LINE.END'; +export const rememberDrawEnd = (point: XY): void => remember(LINE_END, { x: point.x, y: point.y }); +export const lastDrawEnd = (): Point | null => { + const point = recall(LINE_END); + return point ? new Point(point.x, point.y) : null; +}; diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/ellipse-tool.ts b/B07_DesignDetail/openwebcad/src/tools/draw/ellipse-tool.ts new file mode 100644 index 000000000..019c0008c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/draw/ellipse-tool.ts @@ -0,0 +1,176 @@ +/** + * 타원(ELLIPSE) — 337 설계 1-1 · 오토캐드 차례. + * 「타원의 축 끝점 지정 또는 [중심(C)]:」 → 「축의 다른 끝점 지정:」 → 「다른 축으로 거리를 지정 또는 [회전(R)]:」 · + * C → 「타원의 중심 지정:」 → 「축의 끝점 지정:」 → 같은 거리 물음 · R → 「주축 둘레로 회전 지정:」(다른 축 = 축 × cos). + * 다른 축 거리 = 중심에서 잰 반 길이(숫자도 · N10). 하나 그리면 끝. + */ +import { Point } from '@flatten-js/core'; +import { assign, createMachine } from 'xstate'; +import { getPointFromEvent } from '../../helpers/get-point-from-event'; +import { ellipsePoints } from '../../helpers/geometry/shape-points'; +import { + addEntities, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../../state'; +import { Tool } from '../../tools'; +import { lineEntity, polyLineEntity } from '../factories/entity-factory'; +import { + type DrawEvent, + END_STATE, + type NumberInputEvent, + type PointInputEvent, + type StateEvent, + type ToolContext, +} from '../tool.types'; +import { END, firstPointEvents, option, pointEvents } from './draw-steps'; + +export interface EllipseContext extends ToolContext { + /** 축 끝점 방식의 첫 끝 · 중심 방식의 중심 */ + first: Point | null; + center: Point | null; + axisEnd: Point | null; +} + +enum S { + INIT = 'INIT', + AXIS_START = 'AXIS_START', + AXIS_END = 'AXIS_END', + CENTER = 'CENTER', + CENTER_AXIS = 'CENTER_AXIS', + DISTANCE = 'DISTANCE', + ROTATION = 'ROTATION', +} + +const ellipseOf = (context: EllipseContext, other: number) => + polyLineEntity(ellipsePoints(context.center as Point, context.axisEnd as Point, other)); + +const add = (context: EllipseContext, other: number) => { + const ellipse = ellipseOf(context, other); + setGhostHelperEntities([]); + if (ellipse) addEntities([ellipse], true); +}; + +/** 축이 정해짐 — 다른 축 거리의 고무줄 · 거리 숫자 기준 = 중심 */ +const axisDone = (center: Point, axisEnd: Point) => { + setAngleGuideOriginPoint(center); + return { center, axisEnd }; +}; + +export const ellipseToolStateMachine = createMachine( + { + types: {} as { context: EllipseContext; events: StateEvent }, + context: { first: null, center: null, axisEnd: null, type: Tool.ELLIPSE }, + initial: S.INIT, + states: { + [S.INIT]: { always: { actions: 'init', target: S.AXIS_START } }, + [S.AXIS_START]: { + meta: { + kind: 'point', + prompt: { + text: '타원의 축 끝점 지정', + options: [option('CENTER', 'C', '중심')], + absent: [option('ARC', 'A', '호')], + }, + }, + on: { + ...firstPointEvents('recordFirst', S.AXIS_END), + 'OPTION.CENTER': { target: S.CENTER }, + ESC: { target: END_STATE }, + }, + }, + [S.AXIS_END]: { + meta: { kind: 'point', prompt: { text: '축의 다른 끝점 지정' } }, + on: { + DRAW: { actions: 'previewAxis' }, + ...pointEvents('recordAxisEnd', S.DISTANCE), + ESC: { target: END_STATE }, + }, + }, + [S.CENTER]: { + meta: { kind: 'point', prompt: { text: '타원의 중심 지정' } }, + on: { ...firstPointEvents('recordFirst', S.CENTER_AXIS), ESC: { target: END_STATE } }, + }, + [S.CENTER_AXIS]: { + meta: { kind: 'point', prompt: { text: '축의 끝점 지정' } }, + on: { + DRAW: { actions: 'previewAxis' }, + ...pointEvents('recordCenterAxis', S.DISTANCE), + ESC: { target: END_STATE }, + }, + }, + [S.DISTANCE]: { + meta: { + kind: 'point', + prompt: { + text: '다른 축으로 거리를 지정', + options: [option('ROTATION', 'R', '회전')], + number: { positive: true }, + }, + }, + on: { + DRAW: { actions: 'previewDistance' }, + ...pointEvents('drawByDistance', END_STATE), + 'OPTION.ROTATION': { target: S.ROTATION }, + ESC: { target: END_STATE }, + }, + }, + [S.ROTATION]: { + meta: { + kind: 'number', + prompt: { text: '주축 둘레로 회전 지정', number: { min: 0, max: 89.4 } }, + }, + on: { + NUMBER_INPUT: { actions: 'drawByRotation', target: END_STATE }, + ESC: { target: END_STATE }, + }, + }, + ...END, + }, + }, + { + actions: { + init: () => { + setShouldDrawHelpers(true); + setSelectedEntityIds([]); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + }, + recordFirst: assign(({ event }) => { + const first = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(first); + return { first }; + }), + previewAxis: ({ context, event }) => { + const first = context.first as Point; + setGhostHelperEntities([lineEntity(first, getPointFromEvent(first, event as DrawEvent))]); + }, + recordAxisEnd: assign(({ context, event }) => { + const first = context.first as Point; + const end = getPointFromEvent(first, event as PointInputEvent); + return axisDone(new Point((first.x + end.x) / 2, (first.y + end.y) / 2), end); + }), + recordCenterAxis: assign(({ context, event }) => { + const center = context.first as Point; + return axisDone(center, getPointFromEvent(center, event as PointInputEvent)); + }), + previewDistance: ({ context, event }) => { + const center = context.center as Point; + const other = center.distanceTo(getPointFromEvent(center, event as DrawEvent))[0]; + const ellipse = ellipseOf(context, other); + setGhostHelperEntities(ellipse ? [ellipse] : []); + }, + drawByDistance: ({ context, event }) => { + const center = context.center as Point; + add(context, center.distanceTo(getPointFromEvent(center, event as PointInputEvent))[0]); + }, + drawByRotation: ({ context, event }) => { + const axis = (context.center as Point).distanceTo(context.axisEnd as Point)[0]; + const angle = ((event as NumberInputEvent).value * Math.PI) / 180; + add(context, axis * Math.cos(angle)); + }, + }, + } +); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts index a0db57845..d54b6edb3 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts @@ -1,17 +1,44 @@ /** 해치·그라데이션·경계·영역 — 선택한 객체가 이루는 닫힌 경계를 채우거나 뽑는다 */ import type { Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; +import { assign, createMachine, sendTo } from 'xstate'; import { autoHatchSpacing, getHatchAngle, getHatchSpacing, getHatchStyle, } from '../../commands/draw-settings'; +import { + OPT, + type OptionSpec, + type PromptSpec, + SELECT_OBJECTS, + rememberSelection, +} from '../../commands/prompt'; import { entitiesToLoop } from '../../helpers/geometry/entity-loop'; -import { addEntities, getActiveLineColor, setSelectedEntityIds } from '../../state'; +import { isPointInPolygon } from '../../helpers/geometry/hatch-lines'; +import { sampleEntityPoints } from '../../helpers/geometry/sample-entity'; +import { + addEntities, + getActiveLineColor, + getPickableEntities, + getSelectedEntities, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../../state'; import { Tool } from '../../tools'; import { hatchEntity, polyLineEntity } from '../factories/entity-factory'; import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool'; +import { selectToolStateMachine } from '../select-tool'; +import { + type AbsolutePointInputEvent, + ActorEvent, + END_STATE, + type MouseClickEvent, + type StateEvent, + type ToolContext, +} from '../tool.types'; /** 선택 객체에서 닫힌 경계를 얻는다. 못 얻으면 안내 후 빈 배열 */ function loopFromSelection(input: SequenceInput): Point[] { @@ -29,31 +56,150 @@ function boundingSize(points: Point[]): { width: number; height: number } { return { width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) }; } -export const hatchToolStateMachine = createSequenceTool({ - tool: Tool.HATCH, - helpers: false, - steps: [ - { kind: 'selection', instructions: '해치를 채울 경계 객체를 선택한 뒤 ENTER를 누르십시오.' }, +/** 해치 한 벌 — 지금 해치 설정 · 간격 없으면 경계 크기로 */ +function hatchOf(loop: Point[]) { + const { width, height } = boundingSize(loop); + return hatchEntity(loop, { + style: getHatchStyle(), + color: getActiveLineColor(), + spacing: getHatchSpacing() ?? autoHatchSpacing(width, height), + angle: getHatchAngle(), + }); +} + +const loopArea = (loop: Point[]) => + Math.abs( + loop.reduce((sum, point, index) => { + const next = loop[(index + 1) % loop.length]; + return sum + point.x * next.y - next.x * point.y; + }, 0) + ) / 2; + +/** 내부 점 — 그 점을 품은 닫힌 객체(닫힌 폴리선 · 사각형 · 원) 중 가장 작은 하나 */ +function loopAround(point: Point): { id: string; loop: Point[] } | null { + let best: { id: string; loop: Point[]; area: number } | null = null; + for (const entity of getPickableEntities()) { + const loop = sampleEntityPoints(entity); + if (loop.length < 4) continue; + const [first, last] = [loop[0], loop[loop.length - 1]]; + if (Math.hypot(first.x - last.x, first.y - last.y) > 1e-6) continue; // 열린 객체 + if (!isPointInPolygon(loop, point)) continue; + const area = loopArea(loop); + if (!best || area < best.area) best = { id: entity.id, loop, area }; + } + return best; +} + +type HatchContext = ToolContext & { boundaries: { id: string; loop: Point[] }[] }; +type HatchArgs = { context: HatchContext; event: StateEvent }; + +const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label }); +const PICK_PROMPT: PromptSpec = { + text: '내부 점 선택', + options: [opt('SELECT', 'S', '객체 선택'), OPT.UNDO], + absent: [ + opt('REMOVE', 'B', '경계 제거'), + opt('SETTINGS', 'T', '설정'), + opt('DRAW', 'W', '그리기'), ], - commit: (input) => { - const loop = loopFromSelection(input); - if (!loop.length) return; - const { width, height } = boundingSize(loop); - addEntities( - [ - hatchEntity(loop, { - style: getHatchStyle(), - color: getActiveLineColor(), - spacing: getHatchSpacing() ?? autoHatchSpacing(width, height), - angle: getHatchAngle(), - }), - ], - true - ); - setSelectedEntityIds([]); - }, +}; +const SELECT_CHILD = 'hatchSelect'; + +const showBoundaries = (boundaries: HatchContext['boundaries']) => + setGhostHelperEntities(boundaries.flatMap(({ loop }) => polyLineEntity(loop) ?? [])); + +/** 내부 점 하나 — 경계 못 찾으면 안내만 · 같은 경계 두 번은 한 번 */ +const pickAt = assign(({ context, event }: HatchArgs) => { + const at = + event.type === ActorEvent.MOUSE_CLICK + ? (event as MouseClickEvent).worldMouseLocation + : (event as AbsolutePointInputEvent).value; + const found = loopAround(at); + if (!found) { + toast.warn('닫힌 경계를 찾을 수 없음.'); + return {}; + } + if (context.boundaries.some(({ id }) => id === found.id)) return {}; + const boundaries = [...context.boundaries, found]; + showBoundaries(boundaries); + return { boundaries }; }); +const undoPick = assign(({ context }: HatchArgs) => { + const boundaries = context.boundaries.slice(0, -1); + showBoundaries(boundaries); + return { boundaries }; +}); + +/** 모은 경계(+ 고른 객체 경계)마다 해치 하나 */ +const createHatches = ({ context }: { context: HatchContext }) => { + const loops = context.boundaries.map(({ loop }) => loop); + const selected = getSelectedEntities(); + if (selected.length) { + rememberSelection(selected.map((entity) => entity.id)); + const loop = entitiesToLoop(selected); + if (loop.length >= 3) loops.push(loop); + else toast.warn('닫힌 경계를 만들 객체를 선택하십시오.'); + } + setGhostHelperEntities([]); + setSelectedEntityIds([]); + if (loops.length) addEntities(loops.map(hatchOf), true); +}; + +const finish = () => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); +}; + +// biome-ignore lint/suspicious/noExplicitAny: 자식 머신을 품은 상태 구성은 xstate 타입 추론이 안 맞음 +const states: Record = { + PICK: { + meta: { kind: 'point', prompt: PICK_PROMPT }, + entry: () => setShouldDrawHelpers(false), + on: { + MOUSE_CLICK: { actions: pickAt }, + ABSOLUTE_POINT_INPUT: { actions: pickAt }, + 'OPTION.SELECT': { target: 'SELECT' }, + 'OPTION.UNDO': { actions: undoPick }, + ENTER: { actions: createHatches, target: END_STATE }, + ESC: { actions: finish, target: END_STATE }, + }, + }, + SELECT: { + meta: { kind: 'selection', prompt: SELECT_OBJECTS }, + invoke: { + id: SELECT_CHILD, + src: selectToolStateMachine, + onDone: { actions: createHatches, target: END_STATE }, + }, + on: { + MOUSE_CLICK: { actions: sendTo(SELECT_CHILD, ({ event }) => event) }, + ENTER: { actions: sendTo(SELECT_CHILD, ({ event }) => event) }, + DRAW: { actions: sendTo(SELECT_CHILD, ({ event }) => event) }, + 'OPTION.*': { actions: sendTo(SELECT_CHILD, ({ event }) => event) }, + ESC: { actions: finish, target: END_STATE }, + }, + }, + [END_STATE]: { type: 'final' }, +}; + +/** + * HATCH — 「내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:」 되풀이 · 빈 Enter = 만듦 · Esc = 안 만들고 끝. + * 손으로 짬 — 순서 엔진은 앞 점을 기준점으로 직교를 걸어 둘째 내부 점이 끌려감. + */ +export const hatchToolStateMachine = createMachine( + { + types: {} as { context: HatchContext; events: StateEvent }, + context: { type: Tool.HATCH, boundaries: [] }, + initial: 'PICK', + states, + }, + { + // biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다 + actions: { ...(selectToolStateMachine.implementations.actions as any) }, + } +); + export const gradientToolStateMachine = createSequenceTool({ tool: Tool.GRADIENT, helpers: false, diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/pline-tool.ts b/B07_DesignDetail/openwebcad/src/tools/draw/pline-tool.ts new file mode 100644 index 000000000..1b02babb0 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/draw/pline-tool.ts @@ -0,0 +1,277 @@ +/** + * 폴리선(PLINE) — 337 설계 1-1 · 오토캐드 차례. + * 「시작점 지정:」(빈 Enter = 마지막 끝점에서) → 「다음 점 지정 또는 [호(A)/길이(L)/명령 취소(U)]:」 되풀이 · + * 둘째 토막부터 [호(A)/닫기(C)/길이(L)/명령 취소(U)] · 호 모드 「호의 끝점 지정 또는 [닫기(CL)/선(L)/명령 취소(U)]:」. + * 호 = 앞 토막 끝 방향에 접하는 호(첫 토막이면 동쪽 0°) · 길이 = 앞 토막 방향으로 그 길이 · + * 빈 Enter · Esc = 그린 데까지 폴리선 남기고 끝. + */ +import { Point } from '@flatten-js/core'; +import { assign, createMachine } from 'xstate'; +import { OPT, type PromptSpec, type XY } from '../../commands/prompt'; +import type { Entity } from '../../entities/Entity'; +import { PolyLineEntity } from '../../entities/PolyLineEntity'; +import { getPointFromEvent } from '../../helpers/get-point-from-event'; +import type { ArcDefinition } from '../../helpers/geometry/shape-points'; +import { + addEntities, + getActiveLayerId, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../../state'; +import { Tool } from '../../tools'; +import { arcEntity, lineEntity, styled } from '../factories/entity-factory'; +import { + type DrawEvent, + END_STATE, + type NumberInputEvent, + type PointInputEvent, + type StateEvent, + type ToolContext, +} from '../tool.types'; +import { + END, + firstPointEvents, + lastDrawEnd, + option, + pointEvents, + rememberDrawEnd, +} from './draw-steps'; + +/** 토막 하나 — 끝점 · 호면 그 정의(없으면 선) */ +interface Segment { + end: Point; + arc: ArcDefinition | null; +} + +export interface PlineContext extends ToolContext { + start: Point | null; + segments: Segment[]; +} + +enum S { + INIT = 'INIT', + START = 'START', + LINE = 'LINE', + ARC = 'ARC', + LENGTH = 'LENGTH', +} + +const EAST: XY = { x: 1, y: 0 }; + +const lastEnd = (context: PlineContext): Point => + context.segments[context.segments.length - 1]?.end ?? (context.start as Point); + +/** 마지막 토막 끝의 진행 방향(단위) — 선 = 그 방향 · 호 = 끝 접선 · 토막 없음 = 동쪽 */ +function endDirection(context: PlineContext): XY { + const count = context.segments.length; + if (!count) return EAST; + const { end, arc } = context.segments[count - 1]; + if (arc) { + const rx = (end.x - arc.center.x) / arc.radius; + const ry = (end.y - arc.center.y) / arc.radius; + return arc.counterClockwise ? { x: -ry, y: rx } : { x: ry, y: -rx }; + } + const from = count > 1 ? context.segments[count - 2].end : (context.start as Point); + const length = Math.hypot(end.x - from.x, end.y - from.y); + return length > 0 ? { x: (end.x - from.x) / length, y: (end.y - from.y) / length } : EAST; +} + +/** from 에서 방향 dir 에 접하고 to 를 지나는 호 — 한 줄 위면 null(선) */ +export function tangentArc(from: Point, dir: XY, to: Point): ArcDefinition | null { + const dx = to.x - from.x; + const dy = to.y - from.y; + const cross = dir.x * dy - dir.y * dx; + if (Math.abs(cross) < 1e-9) return null; + const signed = (dx * dx + dy * dy) / (2 * cross); // + = 왼쪽 중심(반시계) + const center = new Point(from.x - dir.y * signed, from.y + dir.x * signed); + return { + center, + radius: Math.abs(signed), + startAngle: Math.atan2(from.y - center.y, from.x - center.x), + endAngle: Math.atan2(to.y - center.y, to.x - center.x), + counterClockwise: cross > 0, + }; +} + +const segmentEntity = (from: Point, segment: Segment): Entity => + segment.arc ? arcEntity(segment.arc) : lineEntity(from, segment.end); + +function segmentEntities(context: PlineContext, extra?: Segment): Entity[] { + const list = extra ? [...context.segments, extra] : context.segments; + let from = context.start as Point; + return list.map((segment) => { + const entity = segmentEntity(from, segment); + from = segment.end; + return entity; + }); +} + +const lineTo = (end: Point): Segment => ({ end, arc: null }); +const arcTo = (context: PlineContext, end: Point): Segment => ({ + end, + arc: tangentArc(lastEnd(context), endDirection(context), end), +}); + +const ARC = option('ARC', 'A', '호'); +const LENGTH = option('LENGTH', 'L', '길이'); +const LINE = option('LINE', 'L', '선'); +const ARC_CLOSE = option('CLOSE', 'CL', '닫기'); +const WIDTHS = [option('HALFWIDTH', 'H', '반폭'), option('WIDTH', 'W', '폭')]; +const hasSegment = (context: Partial) => (context.segments?.length ?? 0) > 0; + +const linePrompt = (context: Partial): PromptSpec => ({ + text: '다음 점 지정', + options: hasSegment(context) ? [ARC, OPT.CLOSE, LENGTH, OPT.UNDO] : [ARC, LENGTH, OPT.UNDO], + absent: WIDTHS, +}); +const arcPrompt = (context: Partial): PromptSpec => ({ + text: '호의 끝점 지정', + options: hasSegment(context) ? [ARC_CLOSE, LINE, OPT.UNDO] : [LINE, OPT.UNDO], + absent: [ + option('ANGLE', 'A', '각도'), + option('CENTER', 'CE', '중심'), + option('DIRECTION', 'D', '방향'), + option('RADIUS', 'R', '반지름'), + option('SECOND', 'S', '두 번째 점'), + ...WIDTHS, + ], +}); + +/** 토막 하나 더함 — 기준점 · 끝점 기억 */ +const pushSegment = (context: PlineContext, segment: Segment) => { + setGhostHelperEntities([]); + setAngleGuideOriginPoint(segment.end); + return { segments: [...context.segments, segment] }; +}; + +export const plineToolStateMachine = createMachine( + { + types: {} as { context: PlineContext; events: StateEvent }, + context: { start: null, segments: [], type: Tool.PLINE }, + initial: S.INIT, + states: { + [S.INIT]: { always: { actions: 'init', target: S.START } }, + [S.START]: { + meta: { kind: 'point', prompt: { text: '시작점 지정' } }, + on: { + ...firstPointEvents('recordStart', S.LINE), + ENTER: { guard: () => !!lastDrawEnd(), actions: 'continueFromLast', target: S.LINE }, + ESC: { target: END_STATE }, + }, + }, + [S.LINE]: { + meta: { kind: 'point', prompt: linePrompt }, + on: { + DRAW: { actions: 'previewLine' }, + ...pointEvents('addLine', S.LINE), + 'OPTION.ARC': { target: S.ARC }, + 'OPTION.LENGTH': { target: S.LENGTH }, + 'OPTION.CLOSE': { + guard: 'hasSegment', + actions: ['closeLine', 'commit'], + target: END_STATE, + }, + 'OPTION.UNDO': { actions: 'undoSegment' }, + ENTER: { actions: 'commit', target: END_STATE }, + ESC: { actions: 'commit', target: END_STATE }, + }, + }, + [S.ARC]: { + meta: { kind: 'point', prompt: arcPrompt }, + on: { + DRAW: { actions: 'previewArc' }, + ...pointEvents('addArc', S.ARC), + 'OPTION.LINE': { target: S.LINE }, + 'OPTION.CLOSE': { + guard: 'hasSegment', + actions: ['closeArc', 'commit'], + target: END_STATE, + }, + 'OPTION.UNDO': { actions: 'undoSegment' }, + ENTER: { actions: 'commit', target: END_STATE }, + ESC: { actions: 'commit', target: END_STATE }, + }, + }, + [S.LENGTH]: { + meta: { kind: 'number', prompt: { text: '선의 길이 지정', number: { positive: true } } }, + on: { + NUMBER_INPUT: { actions: 'addLength', target: S.LINE }, + ESC: { actions: 'commit', target: END_STATE }, + }, + }, + ...END, + }, + }, + { + // 옵션 사건은 이름 붙인 guard 로(줄 안 guard 는 글자 사건 꼴과 타입이 안 맞음) + guards: { hasSegment: ({ context }) => hasSegment(context) }, + actions: { + init: assign(() => { + setShouldDrawHelpers(true); + setSelectedEntityIds([]); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + return { start: null, segments: [] }; + }), + recordStart: assign(({ event }) => { + const start = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(start); + return { start }; + }), + continueFromLast: assign(() => { + const start = lastDrawEnd() as Point; + setAngleGuideOriginPoint(start); + return { start }; + }), + previewLine: ({ context, event }) => { + const end = getPointFromEvent(lastEnd(context), event as DrawEvent); + setGhostHelperEntities(segmentEntities(context, lineTo(end))); + }, + previewArc: ({ context, event }) => { + const end = getPointFromEvent(lastEnd(context), event as DrawEvent); + setGhostHelperEntities(segmentEntities(context, arcTo(context, end))); + }, + addLine: assign(({ context, event }) => + pushSegment(context, lineTo(getPointFromEvent(lastEnd(context), event as PointInputEvent))) + ), + addArc: assign(({ context, event }) => + pushSegment( + context, + arcTo(context, getPointFromEvent(lastEnd(context), event as PointInputEvent)) + ) + ), + addLength: assign(({ context, event }) => { + const from = lastEnd(context); + const dir = endDirection(context); + const length = (event as NumberInputEvent).value; + return pushSegment( + context, + lineTo(new Point(from.x + dir.x * length, from.y + dir.y * length)) + ); + }), + closeLine: assign(({ context }) => ({ + segments: [...context.segments, lineTo(context.start as Point)], + })), + closeArc: assign(({ context }) => ({ + segments: [...context.segments, arcTo(context, context.start as Point)], + })), + undoSegment: assign(({ context }) => { + const segments = context.segments.slice(0, -1); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(segments[segments.length - 1]?.end ?? context.start); + return { segments }; + }), + commit: ({ context }) => { + setGhostHelperEntities([]); + if (!context.segments.length) return; + addEntities( + [styled(new PolyLineEntity(getActiveLayerId(), segmentEntities(context)))], + true + ); + rememberDrawEnd(lastEnd(context)); + }, + }, + } +); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/polygon-tool.ts b/B07_DesignDetail/openwebcad/src/tools/draw/polygon-tool.ts new file mode 100644 index 000000000..9a1dd83ed --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/draw/polygon-tool.ts @@ -0,0 +1,220 @@ +/** + * 폴리곤(POLYGON) — 337 설계 1-1 · 오토캐드 차례. + * 「변의 수 입력 <4>:」(3~1024 정수 · 기억) → 「폴리곤의 중심 지정 또는 [모서리(E)]:」 → + * 「옵션 입력 [원에 내접(I)/원에 외접(C)] :」(기억) → 「원의 반지름 지정:」 · + * E → 「모서리의 첫 번째 끝점 지정:」 → 「모서리의 두 번째 끝점 지정:」(그 모서리에서 반시계). + * 반지름 숫자 = 아래 변 수평 · 점 = 크기 + 돌림(내접 = 꼭짓점 · 외접 = 변 가운데). 하나 그리면 끝. + */ +import { Point } from '@flatten-js/core'; +import { assign, createMachine } from 'xstate'; +import { type PromptSpec, recall, remember } from '../../commands/prompt'; +import { getPointFromEvent } from '../../helpers/get-point-from-event'; +import { regularPolygonPoints } from '../../helpers/geometry/shape-points'; +import { + addEntities, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../../state'; +import { Tool } from '../../tools'; +import { polyLineEntity } from '../factories/entity-factory'; +import { + type DrawEvent, + END_STATE, + type NumberInputEvent, + type PointInputEvent, + type StateEvent, + type ToolContext, +} from '../tool.types'; +import { END, firstPointEvents, option, pointEvents } from './draw-steps'; + +export interface PolygonContext extends ToolContext { + sides: number; + center: Point | null; + edgeStart: Point | null; +} + +enum S { + INIT = 'INIT', + SIDES = 'SIDES', + CENTER = 'CENTER', + FIT = 'FIT', + RADIUS = 'RADIUS', + EDGE_START = 'EDGE_START', + EDGE_END = 'EDGE_END', +} + +const SIDES_KEY = 'POLYSIDES'; +const FIT_KEY = 'POLYGON.IC'; +const INSCRIBED = option('INSCRIBED', 'I', '원에 내접'); +const CIRCUMSCRIBED = option('CIRCUMSCRIBED', 'C', '원에 외접'); +const circumscribed = () => recall(FIT_KEY) === CIRCUMSCRIBED.key; + +const SIDES_PROMPT: PromptSpec = { + text: '변의 수 입력', + default: { kind: 'value', remember: SIDES_KEY, initial: 4, format: 'int' }, + number: { int: true, min: 3, max: 1024 }, +}; +const fitPrompt = (): PromptSpec => { + const fit = circumscribed() ? CIRCUMSCRIBED : INSCRIBED; + return { + text: '옵션 입력', + options: [INSCRIBED, CIRCUMSCRIBED], + default: { kind: 'option', key: fit.key, label: fit.kw }, + }; +}; + +/** + * 첫 꼭짓점 — 내접 = 원 위 꼭짓점 · 외접 = 변 가운데가 원 위(꼭짓점은 반 칸 돌아 R / cos(π/n)). + * 각 null = 아래 변 수평. + */ +function firstVertex(center: Point, radius: number, angle: number | null, sides: number): Point { + const half = Math.PI / sides; + const outer = circumscribed(); + const toward = angle ?? -Math.PI / 2 + (outer ? 0 : half); // 아래 변 가운데(외접) · 그 오른쪽 꼭짓점(내접) + const at = outer ? toward - half : toward; + const reach = outer ? radius / Math.cos(half) : radius; + return new Point(center.x + reach * Math.cos(at), center.y + reach * Math.sin(at)); +} + +function polygonFrom(center: Point, point: Point | null, radius: number, sides: number) { + const angle = point ? Math.atan2(point.y - center.y, point.x - center.x) : null; + return polyLineEntity( + regularPolygonPoints(center, firstVertex(center, radius, angle, sides), sides) + ); +} + +/** 모서리 a → b 에서 반시계로 — 중심은 모서리 왼쪽 */ +function polygonOnEdge(a: Point, b: Point, sides: number) { + const length = a.distanceTo(b)[0]; + if (!length) return null; + const apothem = length / 2 / Math.tan(Math.PI / sides); + const center = new Point( + (a.x + b.x) / 2 - ((b.y - a.y) / length) * apothem, + (a.y + b.y) / 2 + ((b.x - a.x) / length) * apothem + ); + return polyLineEntity(regularPolygonPoints(center, a, sides)); +} + +const add = (entity: ReturnType) => { + setGhostHelperEntities([]); + if (entity) addEntities([entity], true); +}; + +export const polygonToolStateMachine = createMachine( + { + types: {} as { context: PolygonContext; events: StateEvent }, + context: { sides: 4, center: null, edgeStart: null, type: Tool.POLYGON }, + initial: S.INIT, + states: { + [S.INIT]: { always: { actions: 'init', target: S.SIDES } }, + [S.SIDES]: { + meta: { kind: 'number', prompt: SIDES_PROMPT }, + on: { + NUMBER_INPUT: { actions: 'recordSides', target: S.CENTER }, + ESC: { target: END_STATE }, + }, + }, + [S.CENTER]: { + meta: { + kind: 'point', + prompt: { text: '폴리곤의 중심 지정', options: [option('EDGE', 'E', '모서리')] }, + }, + on: { + ...firstPointEvents('recordCenter', S.FIT), + 'OPTION.EDGE': { target: S.EDGE_START }, + ESC: { target: END_STATE }, + }, + }, + [S.FIT]: { + meta: { kind: 'keyword', prompt: fitPrompt }, + on: { + 'OPTION.INSCRIBED': { actions: 'recordFit', target: S.RADIUS }, + 'OPTION.CIRCUMSCRIBED': { actions: 'recordFit', target: S.RADIUS }, + ESC: { target: END_STATE }, + }, + }, + [S.RADIUS]: { + meta: { + kind: 'point', + prompt: { text: '원의 반지름 지정', number: { positive: true } }, + }, + on: { + DRAW: { actions: 'previewRadius' }, + ...pointEvents('drawByRadius', END_STATE), + ESC: { target: END_STATE }, + }, + }, + [S.EDGE_START]: { + meta: { kind: 'point', prompt: { text: '모서리의 첫 번째 끝점 지정' } }, + on: { ...firstPointEvents('recordEdge', S.EDGE_END), ESC: { target: END_STATE } }, + }, + [S.EDGE_END]: { + meta: { kind: 'point', prompt: { text: '모서리의 두 번째 끝점 지정' } }, + on: { + DRAW: { actions: 'previewEdge' }, + ...pointEvents('drawByEdge', END_STATE), + ESC: { target: END_STATE }, + }, + }, + ...END, + }, + }, + { + actions: { + init: () => { + setShouldDrawHelpers(true); + setSelectedEntityIds([]); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + }, + recordSides: assign(({ event }) => { + const sides = (event as NumberInputEvent).value; + remember(SIDES_KEY, sides); + return { sides }; + }), + recordCenter: assign(({ event }) => { + const center = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(center); // 반지름 물음의 고무줄 · 거리 숫자 기준 + return { center }; + }), + recordFit: ({ event }) => remember(FIT_KEY, event.type.slice('OPTION.'.length)), + previewRadius: ({ context, event }) => { + const center = context.center as Point; + const point = getPointFromEvent(center, event as DrawEvent); + const polygon = polygonFrom(center, point, center.distanceTo(point)[0], context.sides); + setGhostHelperEntities(polygon ? [polygon] : []); + }, + drawByRadius: ({ context, event }) => { + const center = context.center as Point; + if (event.type === 'NUMBER_INPUT') { + add(polygonFrom(center, null, (event as NumberInputEvent).value, context.sides)); + return; + } + const point = getPointFromEvent(center, event as PointInputEvent); + add(polygonFrom(center, point, center.distanceTo(point)[0], context.sides)); + }, + recordEdge: assign(({ event }) => { + const edgeStart = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(edgeStart); + return { edgeStart }; + }), + previewEdge: ({ context, event }) => { + const start = context.edgeStart as Point; + const polygon = polygonOnEdge( + start, + getPointFromEvent(start, event as DrawEvent), + context.sides + ); + setGhostHelperEntities(polygon ? [polygon] : []); + }, + drawByEdge: ({ context, event }) => { + const start = context.edgeStart as Point; + add( + polygonOnEdge(start, getPointFromEvent(start, event as PointInputEvent), context.sides) + ); + }, + }, + } +); diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts index 9238c284c..50475a2a9 100644 --- a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts @@ -1,15 +1,68 @@ -import { type Circle, Point, type Segment } from '@flatten-js/core'; +import { type Circle, Point, Segment } from '@flatten-js/core'; import { compact } from 'es-toolkit'; import { ArcEntity } from '../entities/ArcEntity'; import type { CircleEntity } from '../entities/CircleEntity'; import type { Entity } from '../entities/Entity'; -import type { LineEntity } from '../entities/LineEntity'; +import { LineEntity } from '../entities/LineEntity'; +import { findClosestEntity } from '../helpers/find-closest-entity'; import { findNeighboringPointsOnArc } from '../helpers/find-neighboring-points-on-arc'; import { findNeighboringPointsOnCircle } from '../helpers/find-neighboring-points-on-circle'; import { findNeighboringPointsOnLine } from '../helpers/find-neighboring-points-on-line'; +import { sampleEntityPoints } from '../helpers/geometry/sample-entity'; +import { intersectLines } from '../helpers/geometry/shape-points'; import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; import { isPointEqual } from '../helpers/is-point-equal'; -import { addEntities, deleteEntities, getActiveLayerId } from '../state'; +import { pickRadius } from '../helpers/pick-radius'; +import { queryEntitiesNearPoint } from '../helpers/spatial-index'; +import { addEntities, deleteEntities, getActiveLayerId, getLayerById } from '../state'; +import { copyStyle, isBetween } from './modify/modify.helpers'; + +/** 누른 자리 집기 반경 안 가장 가까운 객체 — 잠금 도면층 뺌(순서 엔진과 같은 셈) */ +export function pickEntityAt(point: Point): Entity | null { + const radius = pickRadius(); + const candidates = queryEntitiesNearPoint(point.x, point.y, radius).filter( + (entity) => !getLayerById(entity.layerId)?.isLocked + ); + const { distance, entity } = findClosestEntity(point, candidates); + return entity && distance <= radius ? entity : null; +} + +/** 점을 객체 위로 내림(가장 가까운 자리) */ +export const closestPointOn = (entity: Entity, point: Point): Point => + findClosestEntity(point, [entity]).segment?.start ?? point; + +/** EXTEND 빠른 모드 — 누른 쪽 끝을 그 방향 가장 가까운 경계(다른 모든 객체)까지 · 선만 */ +export function extendLineEnd(line: Entity, pick: Point, boundaries: Entity[]): Entity | null { + const shape = line.getShape(); + if (!(shape instanceof Segment)) return null; + const fromStart = pick.distanceTo(shape.start)[0] < pick.distanceTo(shape.end)[0]; + const tip = fromStart ? shape.start : shape.end; + const tail = fromStart ? shape.end : shape.start; + const length = tail.distanceTo(tip)[0]; + if (!length) return null; + const dx = (tip.x - tail.x) / length; + const dy = (tip.y - tail.y) / length; + let best = Number.POSITIVE_INFINITY; + let hit: Point | null = null; + for (const boundary of boundaries) { + if (boundary.id === line.id) continue; + const points = sampleEntityPoints(boundary); + for (let index = 1; index < points.length; index++) { + const crossing = intersectLines(tail, tip, points[index - 1], points[index]); + if (!crossing || !isBetween(crossing, points[index - 1], points[index])) continue; + const ahead = (crossing.x - tip.x) * dx + (crossing.y - tip.y) * dy; + if (ahead > 1e-9 && ahead < best) { + best = ahead; + hit = crossing; + } + } + } + if (!hit) return null; + return copyStyle( + line, + new LineEntity(line.layerId, fromStart ? hit : tail, fromStart ? tail : hit) + ); +} export function getAllIntersectionPoints(entity: Entity, entities: Entity[]): Point[] { // TODO see if we need to make this list unique @@ -39,8 +92,10 @@ export function eraseLineSegment( ); const cutLines: Entity[] = line.cutAtPoints([firstCutPoint, secondCutPoint]); - // Remove the segment that has the clickedPointOnShape point on it - const remainingLines = cutLines.filter((line) => !line.containsPointOnShape(clickedPointOnShape)); + // Remove the segment that has the clickedPointOnShape point on it — 남는 토막은 원래 층 · 꼴(N9) + const remainingLines = cutLines + .filter((piece) => !piece.containsPointOnShape(clickedPointOnShape)) + .map((piece) => copyStyle(line, piece)); deleteEntities([line], false); // Helper functions should never trigger an undo state, since they can be called multiple times during one user operation diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts index 933a54d2e..ca2746b20 100644 --- a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts @@ -1,100 +1,91 @@ +/** + * TRIM · EXTEND 빠른 모드(337 설계 1-2) — 누를 때마다 자름(연장) · Shift+누름 = 반대 일 · 되풀이 · + * 빈 Enter · Esc = 끝 · 집기 반경 밖 누름은 아무 일 없음(N9) · U = 이 명령 안 마지막 하나 · + * TRIM 지우기(R) = 고르기 → Enter 로 지우고 돌아옴. + */ import type { Point, Polygon } from '@flatten-js/core'; -import { assign, createMachine } from 'xstate'; +import { assign, createMachine, sendTo } from 'xstate'; +import { + OPT, + type OptionSpec, + type PromptSpec, + SELECT_OBJECTS, + rememberSelection, +} from '../commands/prompt'; import type { ArcEntity } from '../entities/ArcEntity'; import type { CircleEntity } from '../entities/CircleEntity'; import { EntityName } from '../entities/Entity'; import { LineEntity } from '../entities/LineEntity'; import type { RectangleEntity } from '../entities/RectangleEntity'; import { findClosestEntity } from '../helpers/find-closest-entity'; +import { pickRadius } from '../helpers/pick-radius'; import { polygonToSegments } from '../helpers/polygon-to-segments'; import { addEntities, deleteEntities, - getActiveLayerId, getEntities, getPickableEntities, + getSelectedEntities, setEntities, setGhostHelperEntities, + setSelectedEntityIds, setShouldDrawHelpers, + undo, } from '../state'; import { Tool } from '../tools'; import { eraseArcSegment, eraseCircleSegment, eraseLineSegment, + extendLineEnd, getAllIntersectionPoints, + pickEntityAt, } from './eraser-tool.helpers'; -import type { MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import { copyStyle } from './modify/modify.helpers'; +import { selectToolStateMachine } from './select-tool'; +import { END_STATE, type MouseClickEvent, type StateEvent, type ToolContext } from './tool.types'; export interface EraserContext extends ToolContext { - startPoint: Point | null; + /** 이 명령에서 바꾼 횟수(U) */ + made: number; } export enum EraserState { INIT = 'INIT', WAITING_FOR_FIRST_CLICK = 'WAITING_FOR_FIRST_CLICK', + ERASE_PICK = 'ERASE_PICK', } -export enum EraserAction { - INIT_ERASER_TOOL = 'INIT_ERASER_TOOL', - HANDLE_MOUSE_CLICK = 'HANDLE_MOUSE_CLICK', -} +const absent = (key: string, kw: string, label: string): OptionSpec => ({ + key, + kw, + en: key, + label, +}); +const HIDDEN_UNDO: OptionSpec = { ...OPT.UNDO, hidden: true }; +const CROSSING = absent('CROSSING', 'C', '걸치기'); +const MODE = absent('MODE', 'O', '모드'); +const PROJECT = absent('PROJECT', 'P', '투영'); -export const eraserToolStateMachine = createMachine( - { - types: {} as { - context: EraserContext; - events: StateEvent; - }, - context: { - startPoint: null, - type: Tool.ERASER, - }, - initial: EraserState.INIT, - states: { - [EraserState.INIT]: { - description: 'Initializing the eraser tool', - always: { - actions: EraserAction.INIT_ERASER_TOOL, - target: EraserState.WAITING_FOR_FIRST_CLICK, - }, - }, - [EraserState.WAITING_FOR_FIRST_CLICK]: { - description: '지울 선 선택', - meta: { - instructions: '지울 선 선택', - }, - on: { - // TODO implement a DRAW action to draw the segment that will be erased in dotted line - MOUSE_CLICK: { - actions: EraserAction.HANDLE_MOUSE_CLICK, - target: EraserState.WAITING_FOR_FIRST_CLICK, - }, - }, - }, - // TODO implement rectangle selection to delete - }, - }, - { - actions: { - [EraserAction.INIT_ERASER_TOOL]: assign(() => { - setShouldDrawHelpers(false); - setGhostHelperEntities([]); - return {}; - }), - [EraserAction.HANDLE_MOUSE_CLICK]: assign(({ context, event }) => { - handleMouseClick((event as MouseClickEvent).worldMouseLocation); +const TRIM_PROMPT: PromptSpec = { + text: '자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장', + options: [{ key: 'ERASE', kw: 'R', en: 'ERASE', label: '지우기' }, HIDDEN_UNDO], + absent: [absent('CUTTINGEDGES', 'T', '절단 모서리'), CROSSING, MODE, PROJECT], +}; +const EXTEND_PROMPT: PromptSpec = { + text: '연장할 객체 선택 또는 Shift 키를 누른 채 선택하여 자르기', + options: [HIDDEN_UNDO], + absent: [absent('BOUNDARYEDGES', 'B', '경계 모서리'), CROSSING, MODE, PROJECT], +}; - return context; - }), - }, - } -); - -export function handleMouseClick(worldMouseLocation: Point) { +/** TRIM 한 번 — 누른 자리 반경 안 객체만(없으면 false) · 사각형 변은 원래 층 · 꼴 */ +export function handleMouseClick( + worldMouseLocation: Point, + radius = Number.POSITIVE_INFINITY +): boolean { const closestEntity = findClosestEntity(worldMouseLocation, getPickableEntities()); - if (!closestEntity) { - return; + if (!closestEntity.entity || closestEntity.distance > radius) { + return false; } const clickedPointOnShape = closestEntity.segment.start; @@ -125,8 +116,8 @@ export function handleMouseClick(worldMouseLocation: Point) { case EntityName.Rectangle: { const rectangle = closestEntity.entity as RectangleEntity; const segments = polygonToSegments(rectangle.getShape() as Polygon); - const segmentEntities = segments.map( - (segment) => new LineEntity(getActiveLayerId(), segment) + const segmentEntities = segments.map((segment) => + copyStyle(rectangle, new LineEntity(rectangle.layerId, segment)) ); // Find the closest segment to the clicked point @@ -151,11 +142,129 @@ export function handleMouseClick(worldMouseLocation: Point) { break; } - case EntityName.Image: { - // TODO implement image eraser - // Switch to rectangle entity and delete the image and the line that was closest to the cursor - } + default: + return false; } setEntities(getEntities(), true); // Force a new undo state entry + return true; } + +/** EXTEND 한 번 — 누른 쪽 끝을 가장 가까운 경계까지 · 못 늘리면 false */ +export function extendAt(worldMouseLocation: Point): boolean { + const target = pickEntityAt(worldMouseLocation); + if (!target) return false; + const extended = extendLineEnd(target, worldMouseLocation, getPickableEntities()); + if (!extended) return false; + deleteEntities([target], false); + addEntities([extended], true); + return true; +} + +const trimAt = (location: Point) => handleMouseClick(location, pickRadius()); + +type Ctx = { context: EraserContext; event: StateEvent }; + +/** 빠른 자르기 · 연장 머신 — `act(점, shift)` 가 바꿨으면 true */ +function createQuickEditTool( + tool: Tool, + prompt: PromptSpec, + act: (location: Point, shift: boolean) => boolean +) { + const undoLast = assign(({ context }: Ctx) => { + if (context.made === 0) return {}; + undo(); + return { made: context.made - 1 }; + }); + const end = { target: END_STATE }; + const forward = { actions: sendTo('trimErase', ({ event }: Ctx) => event) }; + const takesErase = prompt.options?.some((option) => option.key === 'ERASE'); + + return createMachine( + { + types: {} as { context: EraserContext; events: StateEvent }, + context: { made: 0, type: tool }, + initial: EraserState.INIT, + // biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음) + states: >{ + [EraserState.INIT]: { + always: { + actions: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + }, + target: EraserState.WAITING_FOR_FIRST_CLICK, + }, + }, + [EraserState.WAITING_FOR_FIRST_CLICK]: { + meta: { kind: 'entity', prompt }, + on: { + MOUSE_CLICK: { + actions: assign(({ context, event }: Ctx) => { + const click = event as MouseClickEvent; + return act(click.worldMouseLocation, !!click.holdingShift) + ? { made: context.made + 1 } + : {}; + }), + }, + ...(takesErase ? { 'OPTION.ERASE': { target: EraserState.ERASE_PICK } } : {}), + 'OPTION.UNDO': { actions: undoLast }, + ENTER: end, + ESC: end, + }, + }, + ...(takesErase + ? { + [EraserState.ERASE_PICK]: { + meta: { + kind: 'selection', + prompt: { text: '지울 객체 선택', options: SELECT_OBJECTS.options }, + }, + entry: () => setShouldDrawHelpers(false), + invoke: { + id: 'trimErase', + src: selectToolStateMachine, + onDone: { + actions: assign(({ context }: Ctx) => { + const picked = getSelectedEntities(); + if (!picked.length) return {}; + rememberSelection(picked.map((entity) => entity.id)); + setSelectedEntityIds([]); + deleteEntities(picked, true); + return { made: context.made + 1 }; + }), + target: EraserState.WAITING_FOR_FIRST_CLICK, + }, + }, + on: { + MOUSE_CLICK: forward, + ENTER: forward, + DRAW: forward, + 'OPTION.*': forward, + ESC: { actions: () => setSelectedEntityIds([]), target: END_STATE }, + }, + }, + } + : {}), + [END_STATE]: { type: 'final' }, + }, + }, + { + // 고르기 자식(선택 도구)의 액션 구현을 함께 넘긴다 + // biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다 + actions: { ...(selectToolStateMachine.implementations.actions as any) }, + } + ); +} + +export const eraserToolStateMachine = createQuickEditTool( + Tool.ERASER, + TRIM_PROMPT, + (location, shift) => (shift ? extendAt(location) : trimAt(location)) +); + +export const extendToolStateMachine = createQuickEditTool( + Tool.EXTEND, + EXTEND_PROMPT, + (location, shift) => (shift ? trimAt(location) : extendAt(location)) +); diff --git a/B07_DesignDetail/openwebcad/src/tools/grip-edit-tool.ts b/B07_DesignDetail/openwebcad/src/tools/grip-edit-tool.ts index 9845aa846..0f60ca6d2 100644 --- a/B07_DesignDetail/openwebcad/src/tools/grip-edit-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/grip-edit-tool.ts @@ -1,10 +1,11 @@ /** * 그립 편집 도구 — 선택 도구에서 그립을 집으면 켜진다(뜨거운 그립). * 오토캐드처럼 누르고-옮기고-누르기 · 끌어 놓기 둘 다(누름 · 뗌 가름은 input-controller) · - * 거리 숫자 = 그립 점에서 커서 쪽(N8) · 좌표 · Esc = 그립 놓음. + * 거리 숫자 = 그립 점에서 커서 쪽(N8) · 좌표 · Esc · 종료(X) = 그립 놓음. */ import type { Point } from '@flatten-js/core'; import { Actor, assign, createMachine } from 'xstate'; +import type { OptionSpec, PromptSpec } from '../commands/prompt'; import type { Entity } from '../entities/Entity'; import { getPointFromEvent } from '../helpers/get-point-from-event'; import { type Grip, applyGrip } from '../helpers/grips'; @@ -20,6 +21,24 @@ import { Tool } from '../tools'; import { selectToolStateMachine } from './select-tool'; import { ActorEvent, type PointInputEvent, type StateEvent, type ToolContext } from './tool.types'; +const gripOption = (key: string, kw: string, label: string): OptionSpec => ({ + key, + kw, + en: key, + label, +}); + +/** 오토캐드 그립 신축 줄 — 종료(X)만 · 기준점(B) · 복사(C) · 명령 취소(U) 는 뺌 */ +const GRIP_PROMPT: PromptSpec = { + text: '** 신축 ** 신축점 지정', + options: [gripOption('EXIT', 'X', '종료')], + absent: [ + gripOption('BASE', 'B', '기준점'), + gripOption('COPY', 'C', '복사'), + gripOption('UNDO', 'U', '명령 취소'), + ], +}; + let editedEntity: Entity | null = null; let editedGrip: Grip | null = null; @@ -64,7 +83,7 @@ export const gripEditToolStateMachine = createMachine( }, [GripEditState.WAITING_FOR_TARGET_POINT]: { description: '그립을 옮길 위치를 지정한다', - meta: { instructions: '** 신축 ** 신축점 지정' }, + meta: { kind: 'point', prompt: GRIP_PROMPT }, on: { [ActorEvent.DRAW]: { actions: GripEditAction.DRAW_TEMP_GRIP_EDIT }, [ActorEvent.MOUSE_CLICK]: { @@ -80,6 +99,7 @@ export const gripEditToolStateMachine = createMachine( actions: [GripEditAction.APPLY_GRIP_EDIT, GripEditAction.SWITCH_TO_SELECT_TOOL], }, [ActorEvent.ESC]: { actions: GripEditAction.SWITCH_TO_SELECT_TOOL }, + 'OPTION.EXIT': { actions: GripEditAction.SWITCH_TO_SELECT_TOOL }, }, }, }, diff --git a/B07_DesignDetail/openwebcad/src/tools/line-tool.ts b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts index 2f02d7f85..3a4bd31ca 100644 --- a/B07_DesignDetail/openwebcad/src/tools/line-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts @@ -1,35 +1,44 @@ +/** + * 선(LINE) — 337 설계 1-1 · 오토캐드 차례. + * 「첫 번째 점 지정:」(빈 Enter = 마지막 선 · 폴리선 · 호 끝점에서) → 「다음 점 지정 또는 [명령 취소(U)]:」 되풀이 · + * 둘째 토막부터 [닫기(C)/명령 취소(U)]. 토막마다 바로 도면에 · U = 마지막 토막(되돌림 한 칸) · C = 첫 점으로 닫고 끝 · + * 빈 Enter · Esc = 끝(그린 토막 남음). + */ import type { Point } from '@flatten-js/core'; -import { LineEntity } from '../entities/LineEntity'; +import { assign, createMachine } from 'xstate'; +import { OPT, type PromptSpec } from '../commands/prompt'; +import type { LineEntity } from '../entities/LineEntity'; +import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; import { addEntities, - deleteEntities, - getActiveLayerId, - getActiveLineColor, - getActiveLineDash, - getActiveLineWidth, - setActiveToolActor, setAngleGuideOriginPoint, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers, + undo, } from '../state'; import { Tool } from '../tools'; -import { Actor, assign, createMachine } from 'xstate'; -import type { - DrawEvent, - PointInputEvent, - StateEvent, - TextInputEvent, - ToolContext, +import { + END, + firstPointEvents, + lastDrawEnd, + pointEvents, + rememberDrawEnd, +} from './draw/draw-steps'; +import { lineEntity } from './factories/entity-factory'; +import { + type DrawEvent, + END_STATE, + type PointInputEvent, + type StateEvent, + type ToolContext, } from './tool.types'; -import { selectToolStateMachine } from './select-tool.ts'; -import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; export interface LineContext extends ToolContext { startPoint: Point | null; /** 이번 이어 그리기의 첫 점 — 닫기(C) */ firstPoint: Point | null; - /** 이번 이어 그리기에서 그린 구간 — 명령취소(U) */ + /** 이번 이어 그리기에서 그린 구간 — 명령 취소(U) */ segments: LineEntity[]; } @@ -42,13 +51,25 @@ export enum LineState { export enum LineAction { INIT_LINE_TOOL = 'INIT_LINE_TOOL', RECORD_START_POINT = 'RECORD_START_POINT', + CONTINUE_FROM_LAST = 'CONTINUE_FROM_LAST', DRAW_TEMP_LINE = 'DRAW_TEMP_LINE', DRAW_FINAL_LINE = 'DRAW_FINAL_LINE', CLOSE_LINE = 'CLOSE_LINE', UNDO_SEGMENT = 'UNDO_SEGMENT', - SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL', } +const START_PROMPT: PromptSpec = { text: '첫 번째 점 지정' }; +const canClose = (context: Partial) => (context.segments?.length ?? 0) >= 2; +const nextPrompt = (context: Partial): PromptSpec => ({ + text: '다음 점 지정', + options: canClose(context) ? [OPT.CLOSE, OPT.UNDO] : [OPT.UNDO], +}); + +const startAt = (startPoint: Point) => { + setAngleGuideOriginPoint(startPoint); + return { startPoint, firstPoint: startPoint, segments: [] }; +}; + export const lineToolStateMachine = createMachine( { types: {} as { @@ -64,161 +85,94 @@ export const lineToolStateMachine = createMachine( initial: LineState.INIT, states: { [LineState.INIT]: { - description: 'Initializing the line tool', always: { actions: LineAction.INIT_LINE_TOOL, target: LineState.WAITING_FOR_START_POINT, }, }, [LineState.WAITING_FOR_START_POINT]: { - description: 'Select the start point of the line', - meta: { - instructions: '첫 점 지정 (x,y):', - }, + meta: { kind: 'point', prompt: START_PROMPT }, on: { - MOUSE_CLICK: { - actions: LineAction.RECORD_START_POINT, + ...firstPointEvents(LineAction.RECORD_START_POINT, LineState.WAITING_FOR_END_POINT), + ENTER: { + guard: () => !!lastDrawEnd(), + actions: LineAction.CONTINUE_FROM_LAST, target: LineState.WAITING_FOR_END_POINT, }, - ABSOLUTE_POINT_INPUT: { - actions: LineAction.RECORD_START_POINT, - target: LineState.WAITING_FOR_END_POINT, - }, - ESC: { - actions: LineAction.SWITCH_TO_SELECT_TOOL, - }, + ESC: { target: END_STATE }, }, }, [LineState.WAITING_FOR_END_POINT]: { - description: 'Select the end point of the line', - meta: { - instructions: '다음 점 지정 또는 [닫기(C)/명령취소(U)]:', - options: ['C', 'U'], - }, + meta: { kind: 'point', prompt: nextPrompt }, on: { - DRAW: { - actions: LineAction.DRAW_TEMP_LINE, + DRAW: { actions: LineAction.DRAW_TEMP_LINE }, + ...pointEvents(LineAction.DRAW_FINAL_LINE, LineState.WAITING_FOR_END_POINT), + 'OPTION.CLOSE': { + guard: 'canClose', + actions: LineAction.CLOSE_LINE, + target: END_STATE, }, - MOUSE_CLICK: { - actions: LineAction.DRAW_FINAL_LINE, - target: LineState.WAITING_FOR_END_POINT, - }, - NUMBER_INPUT: { - actions: LineAction.DRAW_FINAL_LINE, - target: LineState.WAITING_FOR_END_POINT, - }, - ABSOLUTE_POINT_INPUT: { - actions: LineAction.DRAW_FINAL_LINE, - target: LineState.WAITING_FOR_END_POINT, - }, - RELATIVE_POINT_INPUT: { - actions: LineAction.DRAW_FINAL_LINE, - target: LineState.WAITING_FOR_END_POINT, - }, - TEXT_INPUT: [ + 'OPTION.UNDO': [ { - // 닫기 — 둘째 선부터 · 첫 점으로 잇고 끝 - guard: ({ context, event }) => - (event as TextInputEvent).value === 'C' && context.segments.length >= 2, - actions: LineAction.CLOSE_LINE, - target: LineState.INIT, - }, - { - // 명령취소 — 마지막 구간만 - guard: ({ context, event }) => - (event as TextInputEvent).value === 'U' && context.segments.length > 0, + guard: 'hasSegment', actions: LineAction.UNDO_SEGMENT, }, + // 그린 토막 없음 — 첫 점부터 다시 + { actions: LineAction.INIT_LINE_TOOL, target: LineState.WAITING_FOR_START_POINT }, ], - ESC: { - target: LineState.INIT, - }, - ENTER: { - target: LineState.INIT, - }, + ENTER: { target: END_STATE }, + ESC: { target: END_STATE }, }, }, + ...END, }, }, { + // 옵션 사건(`OPTION.*`)은 이름 붙인 guard 로 — 글자 사건 꼴이라 줄 안 guard 는 타입이 안 맞음 + guards: { + canClose: ({ context }) => canClose(context), + hasSegment: ({ context }) => context.segments.length > 0, + }, actions: { [LineAction.INIT_LINE_TOOL]: assign(() => { setShouldDrawHelpers(true); setSelectedEntityIds([]); setGhostHelperEntities([]); setAngleGuideOriginPoint(null); - return { - startPoint: null, - firstPoint: null, - segments: [], - }; - }), - [LineAction.RECORD_START_POINT]: assign(({ event }) => { - const startPoint = getPointFromEvent(null, event as PointInputEvent); - setAngleGuideOriginPoint(startPoint); - return { - startPoint, - firstPoint: startPoint, - segments: [], - }; + return { startPoint: null, firstPoint: null, segments: [] }; }), + [LineAction.RECORD_START_POINT]: assign(({ event }) => + startAt(getPointFromEvent(null, event as PointInputEvent)) + ), + [LineAction.CONTINUE_FROM_LAST]: assign(() => startAt(lastDrawEnd() as Point)), [LineAction.DRAW_TEMP_LINE]: ({ context, event }) => { - const activeLine = new LineEntity( - getActiveLayerId(), - context.startPoint as Point, - getPointFromEvent(context.startPoint, event as DrawEvent) - ); - activeLine.lineColor = getActiveLineColor(); - activeLine.lineWidth = getActiveLineWidth(); - activeLine.lineDash = getActiveLineDash(); - setGhostHelperEntities([activeLine]); + const start = context.startPoint as Point; + setGhostHelperEntities([lineEntity(start, getPointFromEvent(start, event as DrawEvent))]); }, [LineAction.DRAW_FINAL_LINE]: assign(({ context, event }) => { - if (!context.startPoint) { - throw new Error('Start point is not set during DRAW_FINAL_LINE in LineEntity'); - } - - const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); - const activeLine = new LineEntity( - getActiveLayerId(), - context.startPoint as Point, - endPoint - ); - activeLine.lineColor = getActiveLineColor(); - activeLine.lineWidth = getActiveLineWidth(); - activeLine.lineDash = getActiveLineDash(); - addEntities([activeLine], true); - - // Keep drawing from the last point - setGhostHelperEntities([new LineEntity(getActiveLayerId(), endPoint, endPoint)]); + const start = context.startPoint as Point; + const endPoint = getPointFromEvent(start, event as PointInputEvent); + const segment = lineEntity(start, endPoint); + addEntities([segment], true); + rememberDrawEnd(endPoint); + setGhostHelperEntities([]); setAngleGuideOriginPoint(endPoint); - return { - startPoint: endPoint, - segments: [...context.segments, activeLine], - }; + return { startPoint: endPoint, segments: [...context.segments, segment] }; }), [LineAction.CLOSE_LINE]: ({ context }) => { - const closing = new LineEntity( - getActiveLayerId(), - context.startPoint as Point, - context.firstPoint as Point - ); - closing.lineColor = getActiveLineColor(); - closing.lineWidth = getActiveLineWidth(); - closing.lineDash = getActiveLineDash(); - addEntities([closing], true); + const first = context.firstPoint as Point; + addEntities([lineEntity(context.startPoint as Point, first)], true); + rememberDrawEnd(first); }, [LineAction.UNDO_SEGMENT]: assign(({ context }) => { const last = context.segments[context.segments.length - 1]; - deleteEntities([last], true); + undo(); // 토막 하나 = 되돌림 한 칸 const startPoint = last.getStartPoint(); - setGhostHelperEntities([new LineEntity(getActiveLayerId(), startPoint, startPoint)]); + rememberDrawEnd(startPoint); + setGhostHelperEntities([]); setAngleGuideOriginPoint(startPoint); return { startPoint, segments: context.segments.slice(0, -1) }; }), - [LineAction.SWITCH_TO_SELECT_TOOL]: () => { - setActiveToolActor(new Actor(selectToolStateMachine)); - }, }, } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts index 425b352a4..d971a1ea6 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts @@ -1,57 +1,290 @@ -/** 모깎기·모따기·곡선 혼합·끊기 (조사표 2절) */ +/** + * 모깎기·모따기·곡선 혼합·끊기 (조사표 2절 · 337 설계 1-2) + * FILLET · CHAMFER = 오토캐드 차례 — 첫 객체(또는 U · R/D · T · M) → 둘째 객체(Shift = 구석) · 값 · 자르기 모드 기억 · + * 다중이면 쌍 되풀이 · 빈 Enter · Esc = 끝. BREAK = 누른 점이 첫 끊기점 · F = 첫 점 다시 · `@0,0` = 같은 점. + */ +import type { Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; +import { assign, createMachine } from 'xstate'; +import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt'; import type { Entity } from '../../entities/Entity'; -import { addEntities, deleteEntities } from '../../state'; +import { getPointFromEvent } from '../../helpers/get-point-from-event'; +import { + addEntities, + deleteEntities, + getEntities, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setShouldDrawHelpers, + undo, +} from '../../state'; import { Tool } from '../../tools'; +import { closestPointOn, pickEntityAt } from '../eraser-tool.helpers'; import { createSequenceTool } from '../factories/sequence-tool'; +import { + END_STATE, + type MouseClickEvent, + type PointInputEvent, + type StateEvent, + type ToolContext, +} from '../tool.types'; import { blendEntities, breakEntity, chamferLines, filletLines } from './corner.helpers'; import type { CornerResult } from './corner.helpers'; -function applyCorner(first: Entity, second: Entity, result: CornerResult | null): void { +const option = (key: string, kw: string, label: string): OptionSpec => ({ + key, + kw, + en: key, + label, +}); +const entityById = (id: string | null): Entity | undefined => + getEntities().find((entity) => entity.id === id); + +// ---- FILLET · CHAMFER ---- + +interface CornerContext extends ToolContext { + firstId: string | null; + firstPick: Point | null; + multiple: boolean; + /** 이 명령에서 만든 쌍 수(U) */ + made: number; + /** 값 · 자르기 물음 뒤 돌아갈 물음 */ + back: 'FIRST' | 'SECOND'; + /** 방금 누른 쌍을 처리했는가 */ + ok: boolean; +} + +interface CornerSpec { + tool: Tool; + first: string; + second: string; + /** 반지름(R) · 거리(D) */ + value: OptionSpec; + absentFirst: OptionSpec[]; + absentSecond: OptionSpec[]; + /** 값 물음 차례 — shown = 그 열쇠 값을 <기본> 으로 보임 */ + values: Array<{ text: string; remember: string; shown?: string }>; + /** corner = Shift+누름(반지름 · 거리 0) */ + apply( + first: Entity, + firstPick: Point, + second: Entity, + secondPick: Point, + corner: boolean + ): CornerResult | null; +} + +const TRIM_OPTION = option('TRIM', 'T', '자르기'); +const POLYLINE = option('POLYLINE', 'P', '폴리선'); +const TRIM_MODE_PROMPT = (): PromptSpec => ({ + text: '자르기 모드 옵션 입력', + options: [TRIM_OPTION, option('NOTRIM', 'N', '자르지 않기')], + default: { kind: 'option', key: recall('TRIMMODE') ?? 'TRIM' }, +}); + +type CornerCtx = { context: CornerContext; event: StateEvent }; + +function applyPair(spec: CornerSpec, context: CornerContext, click: MouseClickEvent): boolean { + const first = entityById(context.firstId); + const second = pickEntityAt(click.worldMouseLocation); + if (!first || !second || !context.firstPick) return false; + const result = spec.apply( + first, + context.firstPick, + second, + click.worldMouseLocation, + !!click.holdingShift + ); if (!result) { toast.warn('두 직선을 선택해야 합니다. 두 선이 평행하면 처리할 수 없습니다.'); - return; + return false; + } + if (recall('TRIMMODE') === 'NOTRIM') { + if (!result.corner) return false; + addEntities([result.corner], true); + return true; } deleteEntities([first, second], false); addEntities([...result.trimmed, ...(result.corner ? [result.corner] : [])], true); + return true; } -export const filletToolStateMachine = createSequenceTool({ +function createCornerTool(spec: CornerSpec) { + const end = { target: END_STATE }; + const back = [ + { guard: ({ context }: CornerCtx) => context.back === 'SECOND', target: 'SECOND' }, + { target: 'FIRST' }, + ]; + // 값 물음 — 마지막 값 뒤 = 돌아갈 물음 + const valueStates = Object.fromEntries( + spec.values.map((value, index) => { + const store = { + actions: ({ event }: CornerCtx) => + remember(value.remember, (event as { value: number }).value), + }; + const isLast = index === spec.values.length - 1; + return [ + `VALUE_${index}`, + { + meta: { + kind: 'number', + prompt: { + text: value.text, + default: { kind: 'value', remember: value.shown ?? value.remember, initial: 0 }, + number: { min: 0 }, + } satisfies PromptSpec, + }, + on: { + NUMBER_INPUT: isLast + ? back.map((transition) => ({ ...transition, ...store })) + : { ...store, target: `VALUE_${index + 1}` }, + ESC: end, + }, + }, + ]; + }) + ); + + return createMachine({ + types: {} as { context: CornerContext; events: StateEvent }, + context: { + firstId: null, + firstPick: null, + multiple: false, + made: 0, + back: 'FIRST', + ok: false, + type: spec.tool, + }, + initial: 'INIT', + // biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음) + states: >{ + INIT: { + always: { + actions: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + }, + target: 'FIRST', + }, + }, + FIRST: { + meta: { + kind: 'entity', + prompt: { + text: spec.first, + options: [OPT.UNDO, spec.value, TRIM_OPTION, OPT.MULTIPLE], + absent: spec.absentFirst, + } satisfies PromptSpec, + }, + on: { + MOUSE_CLICK: { + guard: ({ event }: CornerCtx) => + !!pickEntityAt((event as MouseClickEvent).worldMouseLocation), + actions: assign(({ event }: CornerCtx) => { + const location = (event as MouseClickEvent).worldMouseLocation; + return { firstId: pickEntityAt(location)?.id ?? null, firstPick: location }; + }), + target: 'SECOND', + }, + [`OPTION.${spec.value.key}`]: { + actions: assign({ back: 'FIRST' as const }), + target: 'VALUE_0', + }, + 'OPTION.TRIM': { target: 'TRIM_MODE' }, + 'OPTION.MULTIPLE': { actions: assign({ multiple: true }) }, + 'OPTION.UNDO': { + actions: assign(({ context }: CornerCtx) => { + if (context.made === 0) return {}; + undo(); + return { made: context.made - 1 }; + }), + }, + ENTER: end, + ESC: end, + }, + }, + SECOND: { + meta: { + kind: 'entity', + prompt: { + text: spec.second, + options: [spec.value], + absent: spec.absentSecond, + } satisfies PromptSpec, + }, + on: { + MOUSE_CLICK: { + actions: assign(({ context, event }: CornerCtx) => { + const ok = applyPair(spec, context, event as MouseClickEvent); + return { ok, made: context.made + (ok ? 1 : 0) }; + }), + target: 'APPLIED', + }, + [`OPTION.${spec.value.key}`]: { + actions: assign({ back: 'SECOND' as const }), + target: 'VALUE_0', + }, + ENTER: end, + ESC: end, + }, + }, + APPLIED: { + always: [ + { guard: ({ context }: CornerCtx) => !context.ok, target: 'SECOND' }, + { guard: ({ context }: CornerCtx) => context.multiple, target: 'FIRST' }, + { target: END_STATE }, + ], + }, + TRIM_MODE: { + meta: { kind: 'keyword', prompt: TRIM_MODE_PROMPT }, + on: { + 'OPTION.TRIM': { actions: () => remember('TRIMMODE', 'TRIM'), target: 'FIRST' }, + 'OPTION.NOTRIM': { actions: () => remember('TRIMMODE', 'NOTRIM'), target: 'FIRST' }, + ESC: end, + }, + }, + ...valueStates, + [END_STATE]: { type: 'final' }, + }, + }); +} + +const radius = () => recall('FILLETRAD') ?? 0; + +export const filletToolStateMachine = createCornerTool({ tool: Tool.FILLET, - helpers: false, - steps: [ - { kind: 'number', instructions: '모깎기 반지름을 입력하십시오 <0>.', defaultValue: 0 }, - { kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' }, - { kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' }, - ], - commit: (input) => { - const first = input.entity(1); - const second = input.entity(2); - applyCorner( - first, - second, - filletLines(first, input.pick(1), second, input.pick(2), input.number(0)) - ); - }, + first: '첫 번째 객체 선택', + second: '두 번째 객체 선택 또는 Shift 키를 누른 채 선택하여 구석 적용', + value: option('RADIUS', 'R', '반지름'), + absentFirst: [POLYLINE], + absentSecond: [], + values: [{ text: '모깎기 반지름 지정', remember: 'FILLETRAD' }], + apply: (first, firstPick, second, secondPick, corner) => + filletLines(first, firstPick, second, secondPick, corner ? 0 : radius()), }); -export const chamferToolStateMachine = createSequenceTool({ +const ANGLE = option('ANGLE', 'A', '각도'); +const METHOD = option('METHOD', 'E', '방법'); + +export const chamferToolStateMachine = createCornerTool({ tool: Tool.CHAMFER, - helpers: false, - steps: [ - { kind: 'number', instructions: '첫 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 }, - { kind: 'number', instructions: '두 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 }, - { kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' }, - { kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' }, + first: '첫 번째 선 선택', + second: '두 번째 선 선택 또는 Shift 키를 누른 채 선택하여 구석 적용', + value: option('DISTANCE', 'D', '거리'), + absentFirst: [POLYLINE, ANGLE, METHOD], + absentSecond: [ANGLE, METHOD], + values: [ + { text: '첫 번째 모따기 거리 지정', remember: 'CHAMFERA' }, + { text: '두 번째 모따기 거리 지정', remember: 'CHAMFERB', shown: 'CHAMFERA' }, ], - commit: (input) => { - const first = input.entity(2); - const second = input.entity(3); - applyCorner( - first, - second, - chamferLines(first, input.pick(2), second, input.pick(3), input.number(0), input.number(1)) - ); + apply: (first, firstPick, second, secondPick, corner) => { + const a = recall('CHAMFERA') ?? 0; + const b = recall('CHAMFERB') ?? 0; + // 거리 0 · Shift = 구석(모깎기 반지름 0 과 같음) + return corner || (a === 0 && b === 0) + ? filletLines(first, firstPick, second, secondPick, 0) + : chamferLines(first, firstPick, second, secondPick, a, b); }, }); @@ -72,18 +305,111 @@ export const blendToolStateMachine = createSequenceTool({ }, }); -export const breakToolStateMachine = createSequenceTool({ - tool: Tool.BREAK, - steps: [ - { kind: 'entity', instructions: '끊을 객체를 선택하십시오.' }, - { kind: 'point', instructions: '첫 번째 끊기 점을 지정하십시오.' }, - { kind: 'point', instructions: '두 번째 끊기 점을 지정하십시오.' }, - ], - commit: (input) => { - const entity = input.entity(0); - const pieces = breakEntity(entity, input.point(1), input.point(2)); - deleteEntities([entity], false); - addEntities(pieces, true); +// ---- BREAK ---- + +interface BreakContext extends ToolContext { + entityId: string | null; + first: Point | null; +} + +type BreakCtx = { context: BreakContext; event: StateEvent }; + +/** 두 끊기점 사이를 지움 · 같은 점 = 틈 없이 둘로 */ +function breakNow(context: BreakContext, event: StateEvent) { + const entity = entityById(context.entityId); + if (!entity || !context.first) return; + const first = closestPointOn(entity, context.first); + const second = closestPointOn(entity, getPointFromEvent(context.first, event as PointInputEvent)); + const same = Math.hypot(first.x - second.x, first.y - second.y) < 1e-9; + const pieces = same ? breakEntity(entity, first) : breakEntity(entity, first, second); + if (same && pieces.length < 2) return; + deleteEntities([entity], false); + addEntities(pieces, true); +} + +const POINT_EVENTS = [ + 'MOUSE_CLICK', + 'ABSOLUTE_POINT_INPUT', + 'RELATIVE_POINT_INPUT', + 'NUMBER_INPUT', +]; +const onPoint = (transition: object) => + Object.fromEntries(POINT_EVENTS.map((type) => [type, transition])); +const FIRST_POINT = option('FIRST', 'F', '첫 번째 점'); + +export const breakToolStateMachine = createMachine({ + types: {} as { context: BreakContext; events: StateEvent }, + context: { entityId: null, first: null, type: Tool.BREAK }, + initial: 'INIT', + states: { + INIT: { + always: { + actions: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + }, + target: 'OBJECT', + }, + }, + OBJECT: { + meta: { kind: 'entity', prompt: { text: '객체 선택' } satisfies PromptSpec }, + on: { + MOUSE_CLICK: { + guard: ({ event }: BreakCtx) => + !!pickEntityAt((event as MouseClickEvent).worldMouseLocation), + actions: assign(({ event }: BreakCtx) => { + const location = (event as MouseClickEvent).worldMouseLocation; + const entity = pickEntityAt(location) as Entity; + const first = closestPointOn(entity, location); + setAngleGuideOriginPoint(first); + return { entityId: entity.id, first }; + }), + target: 'SECOND', + }, + ENTER: { target: END_STATE }, + ESC: { target: END_STATE }, + }, + }, + SECOND: { + meta: { + kind: 'point', + prompt: { text: '두 번째 끊기점 지정', options: [FIRST_POINT] } satisfies PromptSpec, + }, + entry: () => setShouldDrawHelpers(true), + on: { + ...onPoint({ + actions: ({ context, event }: BreakCtx) => breakNow(context, event), + target: END_STATE, + }), + 'OPTION.FIRST': { target: 'FIRST_POINT' }, + ESC: { target: END_STATE }, + }, + }, + FIRST_POINT: { + meta: { kind: 'point', prompt: { text: '첫 번째 끊기점 지정' } satisfies PromptSpec }, + on: { + ...onPoint({ + actions: assign(({ context, event }: BreakCtx) => { + const first = getPointFromEvent(context.first, event as PointInputEvent); + setAngleGuideOriginPoint(first); + return { first }; + }), + target: 'SECOND_POINT', + }), + ESC: { target: END_STATE }, + }, + }, + SECOND_POINT: { + meta: { kind: 'point', prompt: { text: '두 번째 끊기점 지정' } satisfies PromptSpec }, + on: { + ...onPoint({ + actions: ({ context, event }: BreakCtx) => breakNow(context, event), + target: END_STATE, + }), + ESC: { target: END_STATE }, + }, + }, + [END_STATE]: { type: 'final' }, }, }); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/lengthen-tool.ts b/B07_DesignDetail/openwebcad/src/tools/modify/lengthen-tool.ts new file mode 100644 index 000000000..1c5290d84 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/lengthen-tool.ts @@ -0,0 +1,212 @@ +/** + * LENGTHEN(LEN) — 오토캐드 물음 차례(337 설계 1-2). + * 「측정할 객체 선택 또는 [증분(DE)/퍼센트(P)/전체(T)] <전체(T)>:」(누름 = 현재 길이 알림) + * → 방식 값 → 「변경할 객체 선택 또는 [명령 취소(U)]:」 되풀이(빈 Enter · Esc = 끝 · 바꾼 것 남음). + * 손으로 짠 도구(설계 2-14) — 바꾸고 같은 물음에 머무는 되풀이 · U 는 이 명령 안 바꿈만 되돌림. + */ +import { Arc, type Point, Segment } from '@flatten-js/core'; +import { assign, createMachine } from 'xstate'; +import { type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt'; +import { ArcEntity } from '../../entities/ArcEntity'; +import type { Entity } from '../../entities/Entity'; +import { findClosestEntity } from '../../helpers/find-closest-entity'; +import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity'; +import { pickRadius } from '../../helpers/pick-radius'; +import { queryEntitiesNearPoint } from '../../helpers/spatial-index'; +import { notice } from '../../inputController/prompt-input'; +import { addEntities, deleteEntities, getLayerById, setShouldDrawHelpers, undo } from '../../state'; +import { Tool } from '../../tools'; +import { + END_STATE, + type MouseClickEvent, + type NumberInputEvent, + type StateEvent, + type ToolContext, +} from '../tool.types'; +import { copyStyle, lengthenLine } from './modify.helpers'; + +type Mode = 'DELTA' | 'PERCENT' | 'TOTAL'; + +const MODE_OPTIONS: Record = { + DELTA: { key: 'DELTA', kw: 'DE', en: 'DELTA', label: '증분' }, + PERCENT: { key: 'PERCENT', kw: 'P', en: 'PERCENT', label: '퍼센트' }, + TOTAL: { key: 'TOTAL', kw: 'T', en: 'TOTAL', label: '전체' }, +}; +const DYNAMIC: OptionSpec = { key: 'DYNAMIC', kw: 'DY', en: 'DYNAMIC', label: '동적' }; +const ANGLE: OptionSpec = { key: 'ANGLE', kw: 'A', en: 'ANGLE', label: '각도' }; +const UNDO: OptionSpec = { key: 'UNDO', kw: 'U', en: 'UNDO', label: '명령 취소' }; + +/** 방식별 값 물음 — 기억 열쇠 LENGTHEN.DE · .P · .T */ +const VALUE_PROMPTS: Record = { + DELTA: { + text: '증분 길이 입력', + absent: [ANGLE], + default: { kind: 'value', remember: 'LENGTHEN.DE', initial: 0 }, + }, + PERCENT: { + text: '퍼센트 길이 입력', + number: { positive: true }, + default: { kind: 'value', remember: 'LENGTHEN.P', initial: 100 }, + }, + TOTAL: { + text: '전체 길이 지정', + absent: [ANGLE], + number: { positive: true }, + default: { kind: 'value', remember: 'LENGTHEN.T', initial: 1 }, + }, +}; +const REMEMBER_KEY: Record = { + DELTA: 'LENGTHEN.DE', + PERCENT: 'LENGTHEN.P', + TOTAL: 'LENGTHEN.T', +}; + +const lastMode = (): Mode => recall('LENGTHEN.MODE') ?? 'TOTAL'; + +const MEASURE_PROMPT = (): PromptSpec => { + const mode = MODE_OPTIONS[lastMode()]; + return { + text: '측정할 객체 선택', + options: [MODE_OPTIONS.DELTA, MODE_OPTIONS.PERCENT, MODE_OPTIONS.TOTAL], + absent: [DYNAMIC], + default: { kind: 'option', key: mode.key, label: `${mode.label}(${mode.kw})` }, + }; +}; +const TARGET_PROMPT: PromptSpec = { text: '변경할 객체 선택', options: [UNDO] }; + +/** 누른 자리 가장 가까운 객체(잠금 층 뺌 · 반경 안만) */ +function pickAt(point: Point): Entity | null { + const radius = pickRadius(); + const candidates = queryEntitiesNearPoint(point.x, point.y, radius).filter( + (entity) => !getLayerById(entity.layerId)?.isLocked + ); + const { distance, entity } = findClosestEntity(point, candidates); + return entity && distance <= radius ? entity : null; +} + +export function entityLength(entity: Entity): number { + const shape = entity.getShape(); + if (shape instanceof Segment || shape instanceof Arc) return shape.length; + return polylineLength(sampleEntityPoints(entity)); +} + +/** 호 — 누른 쪽 끝을 호 길이 delta 만큼(사이각 0 ~ 360° 안) */ +function lengthenArc(entity: Entity, arc: Arc, delta: number, near: Point): Entity | null { + const radius = Number(arc.r); + const turn = delta / radius; + const sweep = arc.sweep + turn; + if (sweep <= 0 || sweep >= 2 * Math.PI) return null; + const atStart = arc.start.distanceTo(near)[0] < arc.end.distanceTo(near)[0]; + const sign = arc.counterClockwise ? 1 : -1; + const startAngle = atStart ? arc.startAngle - sign * turn : arc.startAngle; + const endAngle = atStart ? arc.endAngle : arc.endAngle + sign * turn; + return copyStyle( + entity, + new ArcEntity(entity.layerId, arc.center, radius, startAngle, endAngle, arc.counterClockwise) + ); +} + +/** 방식 · 값 → 바뀐 객체(못 바꾸면 null) */ +function changed(entity: Entity, mode: Mode, value: number, near: Point): Entity | null { + const shape = entity.getShape(); + if (!(shape instanceof Segment) && !(shape instanceof Arc)) return null; + const length = shape.length; + const target = + mode === 'DELTA' ? length + value : mode === 'PERCENT' ? (length * value) / 100 : value; + if (target <= 1e-9) return null; + const delta = target - length; + return shape instanceof Arc + ? lengthenArc(entity, shape, delta, near) + : lengthenLine(entity, delta, near); +} + +interface LengthenContext extends ToolContext { + mode: Mode; + value: number; + /** 이 명령 안에서 바꾼 횟수 — U 는 이만큼만 되돌림 */ + changes: number; +} + +type Ctx = { context: LengthenContext; event: StateEvent }; + +const toValue = (mode: Mode) => ({ + target: `VALUE_${mode}`, + actions: assign({ mode: () => mode }), +}); + +const clickPoint = (event: StateEvent) => (event as MouseClickEvent).worldMouseLocation; + +export const lengthenToolStateMachine = createMachine({ + types: {} as { context: LengthenContext; events: StateEvent }, + context: { type: Tool.LENGTHEN, mode: 'TOTAL', value: 1, changes: 0 }, + initial: 'MEASURE', + states: { + MEASURE: { + meta: { kind: 'entity', prompt: MEASURE_PROMPT }, + entry: () => setShouldDrawHelpers(false), + on: { + MOUSE_CLICK: { + actions: ({ event }: Ctx) => { + const entity = pickAt(clickPoint(event)); + if (entity) notice(`현재 길이: ${entityLength(entity).toFixed(4)}`); + }, + }, + 'OPTION.DELTA': toValue('DELTA'), + 'OPTION.PERCENT': toValue('PERCENT'), + 'OPTION.TOTAL': toValue('TOTAL'), + ESC: END_STATE, + }, + }, + ...Object.fromEntries( + (Object.keys(VALUE_PROMPTS) as Mode[]).map((mode) => [ + `VALUE_${mode}`, + { + meta: { kind: 'number', prompt: VALUE_PROMPTS[mode] }, + on: { + NUMBER_INPUT: { + target: 'TARGET', + actions: assign(({ event }: Ctx) => { + const value = (event as NumberInputEvent).value; + remember('LENGTHEN.MODE', mode); + remember(REMEMBER_KEY[mode], value); + return { value }; + }), + }, + ESC: END_STATE, + }, + }, + ]) + ), + TARGET: { + meta: { kind: 'entity', prompt: TARGET_PROMPT }, + on: { + MOUSE_CLICK: { + actions: assign(({ context, event }: Ctx) => { + const near = clickPoint(event); + const entity = pickAt(near); + if (!entity) return {}; + const next = changed(entity, context.mode, context.value, near); + if (!next) { + notice('이 객체는 길이를 바꿀 수 없음.'); + return {}; + } + deleteEntities([entity], false); + addEntities([next], true); + return { changes: context.changes + 1 }; + }), + }, + 'OPTION.UNDO': { + actions: assign(({ context }: Ctx) => { + if (!context.changes) return {}; + undo(); + return { changes: context.changes - 1 }; + }), + }, + ENTER: END_STATE, + ESC: END_STATE, + }, + }, + [END_STATE]: { type: 'final' }, + // biome-ignore lint/suspicious/noExplicitAny: 손으로 짠 상태 — 사건마다 assign 꼴이 달라 풀어 둠(sequence-tool 과 같음) + } as Record, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/offset-tool.ts b/B07_DesignDetail/openwebcad/src/tools/modify/offset-tool.ts new file mode 100644 index 000000000..2d2a0d9ae --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/offset-tool.ts @@ -0,0 +1,219 @@ +/** + * OFFSET(337 설계 1-2) — 오토캐드 간격띄우기 차례: 거리(또는 통과점(T) · 지우기(E)) → 객체 → 면의 점 → + * 객체로 되풀이 · 다중(M) = 방금 만든 것을 같은 거리로 되풀이 · U = 이 명령 안 마지막 하나 · 빈 Enter · Esc = 끝. + * 거리 기억 = OFFSETDIST(처음 <통과점>) · 원본 지우기 기억 = OFFSETERASE. + */ +import { type Point, Segment } from '@flatten-js/core'; +import { toast } from 'react-toastify'; +import { assign, createMachine } from 'xstate'; +import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt'; +import type { Entity } from '../../entities/Entity'; +import { findClosestEntity } from '../../helpers/find-closest-entity'; +import { getPointFromEvent } from '../../helpers/get-point-from-event'; +import { + addEntities, + deleteEntities, + getEntities, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setShouldDrawHelpers, + undo, +} from '../../state'; +import { Tool } from '../../tools'; +import { pickEntityAt } from '../eraser-tool.helpers'; +import { + type DrawEvent, + END_STATE, + type MouseClickEvent, + type PointInputEvent, + type StateEvent, + type ToolContext, +} from '../tool.types'; +import { offsetEntity } from './modify.helpers'; + +interface OffsetContext extends ToolContext { + /** 간격띄울 객체 */ + sourceId: string | null; + /** 처음 고른 객체 — 원본 지우기는 이것만 */ + pickedId: string | null; + multiple: boolean; + /** 이 명령에서 만든 수(U) */ + made: number; +} + +type Ctx = { context: OffsetContext; event: StateEvent }; + +const option = (key: string, kw: string, label: string): OptionSpec => ({ + key, + kw, + en: key, + label, +}); +const THROUGH = option('THROUGH', 'T', '통과점'); + +/** 기억한 거리 · null = 통과점 */ +const offsetDistance = (): number | null => { + const distance = recall('OFFSETDIST'); + return typeof distance === 'number' ? distance : null; +}; + +const DISTANCE_PROMPT = (): PromptSpec => { + const distance = offsetDistance(); + return { + text: '간격띄우기 거리 지정', + options: [THROUGH, option('ERASE', 'E', '지우기')], + absent: [option('LAYER', 'L', '도면층')], + default: + distance === null + ? { kind: 'option', key: 'THROUGH' } + : { kind: 'value', remember: 'OFFSETDIST', initial: distance }, + number: { positive: true }, + }; +}; +const ERASE_PROMPT = (): PromptSpec => ({ + text: '간격띄우기 후 원본 객체를 지우시겠습니까?', + options: [OPT.YES, OPT.NO], + default: { kind: 'option', key: recall('OFFSETERASE') ? 'YES' : 'NO' }, +}); +const OBJECT_PROMPT: PromptSpec = { + text: '간격띄우기할 객체 선택', + options: [OPT.EXIT, OPT.UNDO], + default: { kind: 'enter', label: '종료' }, +}; +const SIDE_PROMPT = (context: Partial): PromptSpec => ({ + text: offsetDistance() === null ? '통과점 지정' : '간격띄우기할 면의 점 지정', + options: context.multiple ? [OPT.EXIT, OPT.UNDO] : [OPT.EXIT, OPT.MULTIPLE, OPT.UNDO], + default: { kind: 'enter', label: context.multiple ? '다음 객체' : '종료' }, +}); + +/** 통과점까지 거리 — 선은 무한 직선까지(오토캐드) · 그 밖은 모양까지 */ +function throughDistance(entity: Entity, point: Point): number { + const shape = entity.getShape(); + if (shape instanceof Segment) { + const { start, end } = shape; + const length = start.distanceTo(end)[0] || 1; + return ( + Math.abs((end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)) / + length + ); + } + return findClosestEntity(point, [entity]).distance; +} + +function offsetThrough(source: Entity, point: Point): Entity | null { + return offsetEntity(source, offsetDistance() ?? throughDistance(source, point), point); +} + +const sourceOf = (context: OffsetContext) => + getEntities().find((entity) => entity.id === context.sourceId); + +/** 면의 점 하나 → 새 객체 · 다중이면 그것이 다음 원본 */ +const placeOffset = assign(({ context, event }: Ctx): Partial => { + const source = sourceOf(context); + if (!source) return { sourceId: null }; + const point = getPointFromEvent(null, event as PointInputEvent); + const result = offsetThrough(source, point); + setGhostHelperEntities([]); + if (!result) { + toast.warn('이 객체는 지정한 거리로 간격을 띄울 수 없습니다.'); + return {}; + } + if (recall('OFFSETERASE') && source.id === context.pickedId) deleteEntities([source], false); + addEntities([result], true); + return { made: context.made + 1, sourceId: context.multiple ? result.id : null }; +}); + +const undoLast = assign(({ context }: Ctx): Partial => { + setGhostHelperEntities([]); + if (context.made === 0) return {}; + undo(); + return { made: context.made - 1, sourceId: null, multiple: false }; +}); + +const end = { target: END_STATE }; +const toObject = { actions: assign({ sourceId: null, multiple: false }), target: 'OBJECT' }; + +// biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음) +const states: Record = { + INIT: { + always: { + actions: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + }, + target: 'DISTANCE', + }, + }, + DISTANCE: { + meta: { kind: 'number', prompt: DISTANCE_PROMPT }, + on: { + NUMBER_INPUT: { + actions: ({ event }: Ctx) => remember('OFFSETDIST', (event as { value: number }).value), + target: 'OBJECT', + }, + 'OPTION.THROUGH': { actions: () => remember('OFFSETDIST', 'THROUGH'), target: 'OBJECT' }, + 'OPTION.ERASE': { target: 'ERASE' }, + ESC: end, + }, + }, + ERASE: { + meta: { kind: 'keyword', prompt: ERASE_PROMPT }, + on: { + 'OPTION.YES': { actions: () => remember('OFFSETERASE', true), target: 'DISTANCE' }, + 'OPTION.NO': { actions: () => remember('OFFSETERASE', false), target: 'DISTANCE' }, + ESC: end, + }, + }, + OBJECT: { + meta: { kind: 'entity', prompt: OBJECT_PROMPT }, + entry: () => setShouldDrawHelpers(false), + on: { + MOUSE_CLICK: { + guard: ({ event }: Ctx) => !!pickEntityAt((event as MouseClickEvent).worldMouseLocation), + actions: assign(({ event }: Ctx) => { + const id = pickEntityAt((event as MouseClickEvent).worldMouseLocation)?.id ?? null; + return { sourceId: id, pickedId: id }; + }), + target: 'SIDE', + }, + 'OPTION.EXIT': end, + 'OPTION.UNDO': { actions: undoLast }, + ENTER: end, + ESC: end, + }, + }, + SIDE: { + meta: { kind: 'point', prompt: SIDE_PROMPT }, + entry: () => setShouldDrawHelpers(true), + on: { + DRAW: { + actions: ({ context, event }: Ctx) => { + const source = sourceOf(context); + const preview = + source && offsetThrough(source, getPointFromEvent(null, event as DrawEvent)); + setGhostHelperEntities(preview ? [preview] : []); + }, + }, + MOUSE_CLICK: { actions: placeOffset, target: 'PLACED' }, + ABSOLUTE_POINT_INPUT: { actions: placeOffset, target: 'PLACED' }, + 'OPTION.EXIT': end, + 'OPTION.MULTIPLE': { actions: assign({ multiple: true }) }, + 'OPTION.UNDO': { actions: undoLast, target: 'OBJECT' }, + ENTER: [{ guard: ({ context }: Ctx) => context.multiple, ...toObject }, end], + ESC: end, + }, + }, + /** 다중이면 같은 물음 · 아니면 객체로 */ + PLACED: { + always: [{ guard: ({ context }: Ctx) => !!context.sourceId, target: 'SIDE' }, toObject], + }, + [END_STATE]: { type: 'final' }, +}; + +export const offsetToolStateMachine = createMachine({ + types: {} as { context: OffsetContext; events: StateEvent }, + context: { sourceId: null, pickedId: null, multiple: false, made: 0, type: Tool.OFFSET }, + initial: 'INIT', + states, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts index b49e1bd3f..a5535b388 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts @@ -1,35 +1,27 @@ /** 연장·결합·분해·지우기 (조사표 2절) */ import { toast } from 'react-toastify'; +import { SELECT_OBJECTS } from '../../commands/prompt'; import type { Entity } from '../../entities/Entity'; import { addEntities, deleteEntities, setSelectedEntityIds } from '../../state'; import { Tool } from '../../tools'; import { createSequenceTool } from '../factories/sequence-tool'; -import { explodeEntity, extendLineToBoundary, joinEntities } from './modify.helpers'; +import { explodeEntity, joinEntities } from './modify.helpers'; -export const extendToolStateMachine = createSequenceTool({ - tool: Tool.EXTEND, - helpers: false, - steps: [ - { kind: 'entity', instructions: '경계로 쓸 객체를 선택하십시오.' }, - { kind: 'entity', instructions: '연장할 선을 선택하십시오.' }, - ], - commit: (input) => { - const boundary = input.entity(0); - const target = input.entity(1); - const extended = extendLineToBoundary(target, boundary); - if (!extended) { - toast.warn('경계와 만나도록 연장할 수 없습니다.'); - return; - } - deleteEntities([target], false); - addEntities([extended], true); - }, -}); +/** EXTEND 빠른 모드는 TRIM 과 한 머신 꼴(eraser-tool.ts) */ +export { extendToolStateMachine } from '../eraser-tool'; export const joinToolStateMachine = createSequenceTool({ tool: Tool.JOIN, helpers: false, - steps: [{ kind: 'selection', instructions: '결합할 객체를 선택한 뒤 ENTER.' }], + steps: [ + { + kind: 'selection', + prompt: { + text: '한 번에 결합할 원본 객체 또는 여러 객체 선택', + options: SELECT_OBJECTS.options, + }, + }, + ], commit: (input) => { const entities = input.entities(0); const joined = joinEntities(entities); diff --git a/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts index afee826d8..a1c886ccd 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts @@ -1,152 +1,159 @@ -import type { Point } from '@flatten-js/core'; +/** + * 직사각형(RECTANG) — 337 설계 1-1 · 오토캐드 차례. + * 「첫 번째 구석점 지정:」 → 「다른 구석점 지정 또는 [치수(D)]:」 · + * D → 「직사각형의 길이 지정 <10.0000>:」 → 「직사각형의 폭 지정 <10.0000>:」(기억 RECT.L · RECT.W) → + * 다시 「다른 구석점 지정 또는 [치수(D)]:」(누른 쪽 사분면으로 놓음). 하나 그리면 끝. + */ +import { Point } from '@flatten-js/core'; +import { assign, createMachine } from 'xstate'; +import { type PromptSpec, remember } from '../commands/prompt'; import { RectangleEntity } from '../entities/RectangleEntity'; +import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; import { addEntities, getActiveLayerId, - getActiveLineColor, - getActiveLineDash, - getActiveLineWidth, setAngleGuideOriginPoint, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers, } from '../state'; -import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; import { Tool } from '../tools'; -import { assign, createMachine } from 'xstate'; -import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; +import { END, firstPointEvents, option, pointEvents } from './draw/draw-steps'; +import { styled } from './factories/entity-factory'; +import { + type DrawEvent, + END_STATE, + type NumberInputEvent, + type PointInputEvent, + type StateEvent, + type ToolContext, +} from './tool.types'; export interface RectangleContext extends ToolContext { startPoint: Point | null; + /** 치수(D)로 정한 길이 · 폭 — 있으면 다른 구석 = 사분면만 */ + length: number | null; + width: number | null; } export enum RectangleState { INIT = 'INIT', WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT', WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT', + WAITING_FOR_LENGTH = 'WAITING_FOR_LENGTH', + WAITING_FOR_WIDTH = 'WAITING_FOR_WIDTH', } -export enum RectangleAction { - INIT_RECTANGLE_TOOL = 'INIT_RECTANGLE_TOOL', - RECORD_START_POINT = 'RECORD_START_POINT', - DRAW_TEMP_RECTANGLE = 'DRAW_TEMP_RECTANGLE', - DRAW_FINAL_RECTANGLE = 'DRAW_FINAL_RECTANGLE', +const START_PROMPT: PromptSpec = { + text: '첫 번째 구석점 지정', + absent: [ + option('CHAMFER', 'C', '모따기'), + option('ELEVATION', 'E', '고도'), + option('FILLET', 'F', '모깎기'), + option('THICKNESS', 'T', '두께'), + option('WIDTH', 'W', '폭'), + ], +}; +const END_PROMPT: PromptSpec = { + text: '다른 구석점 지정', + options: [option('DIMENSIONS', 'D', '치수')], + absent: [option('AREA', 'A', '면적'), option('ROTATION', 'R', '회전')], +}; +const sizePrompt = (text: string, key: string): PromptSpec => ({ + text, + default: { kind: 'value', remember: key, initial: 10 }, + number: { positive: true }, +}); + +/** 다른 구석 — 치수가 있으면 누른 쪽 사분면에 길이 × 폭 */ +function cornerOf(context: RectangleContext, point: Point): Point { + const start = context.startPoint as Point; + if (context.length === null || context.width === null) return point; + const sx = point.x < start.x ? -1 : 1; + const sy = point.y < start.y ? -1 : 1; + return new Point(start.x + sx * context.length, start.y + sy * context.width); } +const rectangle = (context: RectangleContext, point: Point) => + styled( + new RectangleEntity(getActiveLayerId(), context.startPoint as Point, cornerOf(context, point)) + ); + export const rectangleToolStateMachine = createMachine( { - types: {} as { - context: RectangleContext; - events: StateEvent; - }, - context: { - startPoint: null, - type: Tool.RECTANGLE, - }, + types: {} as { context: RectangleContext; events: StateEvent }, + context: { startPoint: null, length: null, width: null, type: Tool.RECTANGLE }, initial: RectangleState.INIT, states: { [RectangleState.INIT]: { - description: 'Initializing the rectangle tool', - always: { - actions: RectangleAction.INIT_RECTANGLE_TOOL, - target: RectangleState.WAITING_FOR_START_POINT, - }, + always: { actions: 'init', target: RectangleState.WAITING_FOR_START_POINT }, }, [RectangleState.WAITING_FOR_START_POINT]: { - description: '첫 모서리 지정 (x,y):', - meta: { - instructions: '첫 모서리 지정 (x,y):', - }, + meta: { kind: 'point', prompt: START_PROMPT }, on: { - MOUSE_CLICK: { - actions: RectangleAction.RECORD_START_POINT, - target: RectangleState.WAITING_FOR_END_POINT, - }, - ABSOLUTE_POINT_INPUT: { - actions: RectangleAction.RECORD_START_POINT, - target: RectangleState.WAITING_FOR_END_POINT, - }, + ...firstPointEvents('recordStart', RectangleState.WAITING_FOR_END_POINT), + ESC: { target: END_STATE }, }, }, [RectangleState.WAITING_FOR_END_POINT]: { - description: '맞은 모서리 지정 (@x,y):', - meta: { - instructions: '맞은 모서리 지정 (@x,y):', - }, + meta: { kind: 'point', prompt: END_PROMPT }, on: { - DRAW: { - actions: RectangleAction.DRAW_TEMP_RECTANGLE, - }, - MOUSE_CLICK: { - actions: RectangleAction.DRAW_FINAL_RECTANGLE, - target: RectangleState.INIT, - }, - NUMBER_INPUT: { - actions: RectangleAction.DRAW_FINAL_RECTANGLE, // TODO see if we want to add a flow where you enter the width and then the height if one of the dimensions of the "direction + distance" comes out to 0 - target: RectangleState.INIT, - }, - ABSOLUTE_POINT_INPUT: { - actions: RectangleAction.DRAW_FINAL_RECTANGLE, - target: RectangleState.INIT, - }, - RELATIVE_POINT_INPUT: { - actions: RectangleAction.DRAW_FINAL_RECTANGLE, - target: RectangleState.INIT, - }, - ESC: { - target: RectangleState.INIT, - }, + DRAW: { actions: 'preview' }, + ...pointEvents('draw', END_STATE), + 'OPTION.DIMENSIONS': { target: RectangleState.WAITING_FOR_LENGTH }, + ESC: { target: END_STATE }, }, }, + [RectangleState.WAITING_FOR_LENGTH]: { + meta: { kind: 'number', prompt: sizePrompt('직사각형의 길이 지정', 'RECT.L') }, + on: { + NUMBER_INPUT: { actions: 'recordLength', target: RectangleState.WAITING_FOR_WIDTH }, + ESC: { target: END_STATE }, + }, + }, + [RectangleState.WAITING_FOR_WIDTH]: { + meta: { kind: 'number', prompt: sizePrompt('직사각형의 폭 지정', 'RECT.W') }, + on: { + NUMBER_INPUT: { actions: 'recordWidth', target: RectangleState.WAITING_FOR_END_POINT }, + ESC: { target: END_STATE }, + }, + }, + ...END, }, }, { actions: { - [RectangleAction.INIT_RECTANGLE_TOOL]: () => { + init: assign(() => { setShouldDrawHelpers(true); setGhostHelperEntities([]); setSelectedEntityIds([]); setAngleGuideOriginPoint(null); - }, - [RectangleAction.RECORD_START_POINT]: assign(({ event }) => { + return { startPoint: null, length: null, width: null }; + }), + recordStart: assign(({ event }) => { const startPoint = getPointFromEvent(null, event as PointInputEvent); setAngleGuideOriginPoint(startPoint); - return { - startPoint, - }; + return { startPoint }; }), - [RectangleAction.DRAW_TEMP_RECTANGLE]: ({ context, event }) => { - if (!context.startPoint) { - throw new Error('[RECTANGLE]: calling draw without start point being set'); - } - const activeRectangle = new RectangleEntity( - getActiveLayerId(), - context.startPoint as Point, - (event as DrawEvent).drawController.getWorldMouseLocation() - ); - activeRectangle.lineColor = getActiveLineColor(); - activeRectangle.lineWidth = getActiveLineWidth(); - activeRectangle.lineDash = getActiveLineDash(); - setGhostHelperEntities([activeRectangle]); + preview: ({ context, event }) => { + const point = getPointFromEvent(context.startPoint, event as DrawEvent); + setGhostHelperEntities([rectangle(context, point)]); }, - [RectangleAction.DRAW_FINAL_RECTANGLE]: ({ context, event }) => { - if (!context.startPoint) { - throw Error( - 'Trying to DRAW_FINAL_RECTANGLE when startPoint is not defined in rectangle-tool' - ); - } - const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); - - const activeRectangle = new RectangleEntity( - getActiveLayerId(), - context.startPoint as Point, - endPoint - ); - activeRectangle.lineColor = getActiveLineColor(); - activeRectangle.lineWidth = getActiveLineWidth(); - activeRectangle.lineDash = getActiveLineDash(); - addEntities([activeRectangle], true); + draw: ({ context, event }) => { + const point = getPointFromEvent(context.startPoint, event as PointInputEvent); + setGhostHelperEntities([]); + addEntities([rectangle(context, point)], true); }, + recordLength: assign(({ event }) => { + const length = (event as NumberInputEvent).value; + remember('RECT.L', length); + return { length }; + }), + recordWidth: assign(({ event }) => { + const width = (event as NumberInputEvent).value; + remember('RECT.W', width); + return { width }; + }), }, } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts index 4ec7b98ed..97decd56b 100644 --- a/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts @@ -31,6 +31,7 @@ import { setSelectedEntityIds, } from '../state'; import { Tool } from '../tools'; +import { previousSelection } from '../commands/prompt'; import { startGripEdit } from './grip-edit-tool'; import type { SelectContext } from './select-tool'; import type { MouseClickEvent } from './tool.types'; @@ -113,6 +114,26 @@ export function combineSelection(ids: string[], mode: 'add' | 'remove' | 'toggle return [...before, ...ids.filter((id) => !before.includes(id))]; } +/** 알림 줄 — 입력 층이 붙임(`ic/selecting.ts` · prompt-input 을 바로 부르면 불러오기 고리) */ +let notice: (text: string) => void = () => {}; +export const setKeywordNotice = (sink: (text: string) => void): void => { + notice = sink; +}; + +/** 고르기 키워드 — 전체(ALL = Ctrl+A 와 같은 목록) · 최종(LAST = 집을 수 있는 마지막) · 이전(P) · 앞 고름에 더함 */ +export function selectByKeyword(key: 'ALL' | 'LAST' | 'PREVIOUS'): void { + const pickable = getPickableEntities(); + const found = + key === 'ALL' + ? pickable.map((entity) => entity.id) + : key === 'LAST' + ? pickable.slice(-1).map((entity) => entity.id) + : previousSelection().filter((id) => pickable.some((entity) => entity.id === id)); + const ids = combineSelection(expandSelectionWithGroups(found), 'add'); + setSelectedEntityIds(ids); + notice(`${found.length}개를 찾음, 총 ${ids.length}개`); +} + /** 그립 집기 · 그리기 — 쉴 때 · 고칠 수 있는 도면만 */ export const gripsEditable = (): boolean => !isViewOnly() && diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts index 91d8770d1..f529d08d7 100644 --- a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts @@ -14,6 +14,7 @@ import { assign, createMachine } from 'xstate'; import { drawTempSelectionRectangle, handleFirstSelectionPoint, + selectByKeyword, selectEntitiesInsideRectangle, } from './select-tool.helpers'; @@ -76,6 +77,10 @@ export const selectToolStateMachine = createMachine( actions: SelectAction.DELETE_SELECTED_ENTITIES, target: SelectState.INIT, }, + // 고르기 숨은 키워드(SELECT_OBJECTS) — 전체 · 최종 · 이전 + 'OPTION.ALL': { actions: () => selectByKeyword('ALL') }, + 'OPTION.LAST': { actions: () => selectByKeyword('LAST') }, + 'OPTION.PREVIOUS': { actions: () => selectByKeyword('PREVIOUS') }, }, }, [SelectState.CHECK_SELECTION]: { diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts index 84706b436..6babd6ab6 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts @@ -1,6 +1,8 @@ /** 조회 명령 — 거리·반지름·각도·면적·좌표·리스트·계산기 (조사표 3절 유틸리티) */ import { Circle, type Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; +import type { OptionSpec } from '../../commands/prompt'; +import { logLine } from '../../commands/run-command'; import { EntityName } from '../../entities/Entity'; import { entitiesToLoop } from '../../helpers/geometry/entity-loop'; import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity'; @@ -22,21 +24,30 @@ export function polygonArea(points: Point[]): number { return Math.abs(total) / 2; } +const MULTIPLE_POINTS: OptionSpec = { key: 'MULTIPLE', kw: 'M', en: 'MULTIPLE', label: '다중 점' }; + +/** DIST 결과 두 줄(오토캐드 꼴 · 길이 소수 넷 · 각도 AUPREC 0) */ +export function distanceReport(first: Point, second: Point): [string, string] { + const dx = second.x - first.x; + const dy = second.y - first.y; + const angle = (toDegrees(Math.atan2(dy, dx)) + 360) % 360; + return [ + `거리 = ${format(Math.hypot(dx, dy), 4)}, XY 평면에서의 각도 = ${Math.round(angle) % 360}, XY 평면으로부터의 각도 = 0`, + `X 증분 = ${format(dx, 4)}, Y 증분 = ${format(dy, 4)}, Z 증분 = ${format(0, 4)}`, + ]; +} + export const distanceToolStateMachine = createSequenceTool({ tool: Tool.DIST, steps: [ - { kind: 'point', instructions: '거리를 잴 첫 점을 지정하십시오.' }, - { kind: 'point', instructions: '두 번째 점을 지정하십시오.' }, + { kind: 'point', prompt: { text: '첫 번째 점 지정' } }, + { kind: 'point', prompt: { text: '두 번째 점 지정', absent: [MULTIPLE_POINTS] } }, ], commit: (input) => { const [first, second] = input.points(); - const distance = first.distanceTo(second)[0]; - const angle = toDegrees(Math.atan2(second.y - first.y, second.x - first.x)); - toast.info( - `거리 ${format(distance)} · X 증분 ${format(second.x - first.x)} · Y 증분 ${format( - second.y - first.y - )} · 각도 ${format(angle, 2)}°` - ); + const lines = distanceReport(first, second); + for (const line of lines) logLine(line); + toast.info(lines.join('\n')); }, }); diff --git a/resources/tester/cad/draw-acad.test.ts b/resources/tester/cad/draw-acad.test.ts new file mode 100644 index 000000000..2ab8282e1 --- /dev/null +++ b/resources/tester/cad/draw-acad.test.ts @@ -0,0 +1,491 @@ +/** + * 337-3a 나-1 그리기 8 — LINE · PLINE · RECTANG · CIRCLE · POLYGON · ARC · ELLIPSE · SPLINE 를 오토캐드 차례로. + * 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-1 · 6-3 나 줄. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad draw-acad + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("react-toastify", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }), +})); + +import { remember } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt"; +import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools"; +import { + activeTool, + click, + entities, + initIdle, + key, + move, + noticeLine, + promptLine, + type, + typed, +} from "./helpers/cad-drive"; + +type Xy = { x: number; y: number }; +type Ends = { getStartPoint(): Xy; getEndPoint(): Xy }; + +const enter = () => key("Enter"); +const esc = () => key("Escape"); +const expectXy = (point: Xy, x: number, y: number) => { + expect(point.x).toBeCloseTo(x, 3); + expect(point.y).toBeCloseTo(y, 3); +}; +const lines = () => entities("Line") as unknown as Ends[]; +const expectLine = (line: Ends, a: [number, number], b: [number, number]) => { + expectXy(line.getStartPoint(), ...a); + expectXy(line.getEndPoint(), ...b); +}; + +beforeEach(() => { + initIdle(); + for (const name of ["LINE.END", "CIRCLERAD", "RECT.L", "RECT.W", "POLYSIDES", "POLYGON.IC"]) { + remember(name, undefined); // 세션 기억 — 시험끼리 안 섞임 + } +}); + +describe("LINE", () => { + it("U = 마지막 토막 · 그 시작점에서 이음 · 그린 토막 없으면 첫 점부터", () => { + type("l"); + expect(promptLine()).toBe("첫 번째 점 지정:"); + type("0,0"); + expect(promptLine()).toBe("다음 점 지정 또는 [명령 취소(U)]:"); + type("10,0"); + type("10,10"); + type("u"); + expect(lines()).toHaveLength(1); + expect(promptLine()).toBe("다음 점 지정 또는 [명령 취소(U)]:"); + type("20,0"); + expect(lines()).toHaveLength(2); + expectLine(lines()[1], [10, 0], [20, 0]); + type("u"); + type("u"); + type("u"); + expect(lines()).toHaveLength(0); + expect(promptLine()).toBe("첫 번째 점 지정:"); + expect(activeTool()).toBe(Tool.LINE); + }); + + it("C 는 둘째 토막부터 — 그 전엔 거절 · 닫고 끝 → 쉼", () => { + type("l"); + type("0,0"); + type("10,0"); + type("c"); + expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); + expect(lines()).toHaveLength(1); + type("10,10"); + expect(promptLine()).toBe("다음 점 지정 또는 [닫기(C)/명령 취소(U)]:"); + type("C"); + expect(lines()).toHaveLength(3); + expectLine(lines()[2], [10, 10], [0, 0]); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("Enter = 끝 → 쉼 · Space = 다시 LINE · 첫 점 빈 Enter = 마지막 끝점에서 이음", () => { + type("l"); + type("0,0"); + type("10,0"); + enter(); + expect(activeTool()).toBe(Tool.SELECT); + expect(lines()).toHaveLength(1); + key(" "); + expect(activeTool()).toBe(Tool.LINE); + expect(promptLine()).toBe("첫 번째 점 지정:"); + key(" "); + expect(promptLine()).toBe("다음 점 지정 또는 [명령 취소(U)]:"); + type("10,5"); + expectLine(lines()[1], [10, 0], [10, 5]); + }); + + it("첫 점 빈 Enter — 그린 것 없으면 아무 일 없음", () => { + type("l"); + enter(); + expect(activeTool()).toBe(Tool.LINE); + expect(promptLine()).toBe("첫 번째 점 지정:"); + }); + + it("Esc = 끝 · 그린 토막 남음", () => { + type("l"); + type("0,0"); + type("10,0"); + type("20,0"); + move(30, 5); + esc(); + expect(activeTool()).toBe(Tool.SELECT); + expect(lines()).toHaveLength(2); + }); +}); + +type Box = { xmin: number; ymin: number; xmax: number; ymax: number }; +type Shape = { getBoundingBox(): Box; getType(): string }; +type Poly = Shape & { getEntities(): (Shape & Ends)[] }; +const expectBox = (shape: Shape, xmin: number, ymin: number, xmax: number, ymax: number) => { + const box = shape.getBoundingBox(); + expect([box.xmin, box.ymin, box.xmax, box.ymax].map((v) => Number(v.toFixed(3)))).toEqual([ + xmin, + ymin, + xmax, + ymax, + ]); +}; +const polys = () => entities("PolyLine") as unknown as Poly[]; +const arcShape = (arc: Shape) => + (arc as unknown as { getShape(): { center: Xy; r: number } }).getShape(); + +describe("PLINE", () => { + it("L = 앞 방향 길이 · U · A 접선 호 · L 선 · 호 뒤 길이 = 접선 · C 닫기", () => { + type("pline"); + expect(promptLine()).toBe("시작점 지정:"); + type("0,0"); + expect(promptLine()).toBe("다음 점 지정 또는 [호(A)/길이(L)/명령 취소(U)]:"); + type("10,0"); + expect(promptLine()).toBe("다음 점 지정 또는 [호(A)/닫기(C)/길이(L)/명령 취소(U)]:"); + type("l"); + expect(promptLine()).toBe("선의 길이 지정:"); + type("5"); + type("u"); + type("a"); + expect(promptLine()).toBe("호의 끝점 지정 또는 [닫기(CL)/선(L)/명령 취소(U)]:"); + type("20,10"); + type("l"); + type("L"); + type("5"); + type("c"); + expect(activeTool()).toBe(Tool.SELECT); + expect(polys()).toHaveLength(1); + const parts = polys()[0].getEntities(); + expect(parts.map((part) => part.getType())).toEqual(["Line", "Arc", "Line", "Line"]); + expectLine(parts[0], [0, 0], [10, 0]); + expectXy(arcShape(parts[1]).center, 10, 10); // 동쪽 접선 → 중심 (10, 10) + expectLine(parts[2], [20, 10], [20, 15]); // 호 끝 접선 = 북쪽 + expectLine(parts[3], [20, 15], [0, 0]); + }); + + it("첫 토막 호 = 동쪽 접선 · CL = 호로 닫음 · 호 모드 C 는 거절", () => { + type("pline"); + type("0,0"); + type("a"); + expect(promptLine()).toBe("호의 끝점 지정 또는 [선(L)/명령 취소(U)]:"); + type("10,10"); + type("c"); + expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); + type("cl"); + const parts = polys()[0].getEntities(); + expect(parts.map((part) => part.getType())).toEqual(["Arc", "Arc"]); + expectXy(arcShape(parts[1]).center, 0, 10); + expectXy(parts[1].getEndPoint(), 0, 0); + }); + + it("Esc = 그린 데까지 남김 · 시작점만이면 없음 · 빈 Enter 끝 · 시작점 Enter = 마지막 끝점", () => { + type("pline"); + type("0,0"); + type("10,0"); + type("20,5"); + move(40, 40); + esc(); + expect(activeTool()).toBe(Tool.SELECT); + expect(polys()).toHaveLength(1); + expect(polys()[0].getEntities()).toHaveLength(2); + type("pline"); + type("1,1"); + esc(); + expect(polys()).toHaveLength(1); + type("pline"); + enter(); + type("@0,10"); + enter(); + expect(polys()).toHaveLength(2); + expectLine(polys()[1].getEntities()[0], [20, 5], [20, 15]); + }); +}); + +describe("RECTANG", () => { + it("D = 길이 · 폭(기억) → 누른 쪽 사분면 · 없는 옵션 안내 · 하나 뒤 끝", () => { + type("rec"); + expect(promptLine()).toBe("첫 번째 구석점 지정:"); + type("e"); + expect(noticeLine()).toBe("「고도(E)」 옵션은 이 CAD 에 없음."); + type("0,0"); + expect(promptLine()).toBe("다른 구석점 지정 또는 [치수(D)]:"); + type("d"); + expect(promptLine()).toBe("직사각형의 길이 지정 <10.0000>:"); + type("30"); + expect(promptLine()).toBe("직사각형의 폭 지정 <10.0000>:"); + type("20"); + expect(promptLine()).toBe("다른 구석점 지정 또는 [치수(D)]:"); + type("-1,-1"); + expect(activeTool()).toBe(Tool.SELECT); + expectBox(entities("Rectangle")[0], -30, -20, 0, 0); + key(" "); + type("100,100"); + type("d"); + expect(promptLine()).toBe("직사각형의 길이 지정 <30.0000>:"); + enter(); + expect(promptLine()).toBe("직사각형의 폭 지정 <20.0000>:"); + enter(); + type("101,99"); + expectBox(entities("Rectangle")[1], 100, 80, 130, 100); + }); + + it("두 구석 · Esc 첫 구석 = 끝", () => { + type("rec"); + esc(); + expect(activeTool()).toBe(Tool.SELECT); + type("rec"); + type("0,0"); + type("@5,3"); + expectBox(entities("Rectangle")[0], 0, 0, 5, 3); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +describe("CIRCLE", () => { + const radius = (index: number) => + (entities("Circle")[index] as unknown as { getRadius(): number }).getRadius(); + + it("반지름 · 처음엔 <> 없음(Enter 아무 일 없음) · 기억 · 한 번 뒤 끝 · 음수 거절 · Ttr 없음", () => { + type("c"); + expect(promptLine()).toBe("원에 대한 중심점 지정 또는 [3점(3P)/2점(2P)]:"); + type("t"); + expect(noticeLine()).toBe("「Ttr - 접선 접선 반지름(T)」 옵션은 이 CAD 에 없음."); + type("0,0"); + expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)]:"); + enter(); + expect(entities("Circle")).toHaveLength(0); + type("-5"); + expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); + type("5"); + expect(activeTool()).toBe(Tool.SELECT); + expect(radius(0)).toBeCloseTo(5); + key(" "); + type("20,0"); + expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)] <5.0000>:"); + enter(); + expect(radius(1)).toBeCloseTo(5); + expectBox(entities("Circle")[1], 15, -5, 25, 5); + }); + + it("D 지름 <2r> · 누른 점 반지름도 기억", () => { + remember("CIRCLERAD", 3); + type("c"); + type("0,0"); + type("d"); + expect(promptLine()).toBe("원의 지름 지정 <6.0000>:"); + type("8"); + expect(radius(0)).toBeCloseTo(4); + type("c"); + type("50,50"); + expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)] <4.0000>:"); + type("@0,7"); + expect(radius(1)).toBeCloseTo(7); + type("c"); + type("90,90"); + expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)] <7.0000>:"); + }); + + it("3P · 2P", () => { + type("c"); + type("3p"); + expect(promptLine()).toBe("원 위의 첫 번째 점 지정:"); + type("0,0"); + expect(promptLine()).toBe("원 위의 두 번째 점 지정:"); + type("10,0"); + expect(promptLine()).toBe("원 위의 세 번째 점 지정:"); + type("5,5"); + expect(activeTool()).toBe(Tool.SELECT); + expectBox(entities("Circle")[0], 0, -5, 10, 5); + type("c"); + type("2P"); + expect(promptLine()).toBe("원 지름의 첫 번째 끝점 지정:"); + type("20,0"); + expect(promptLine()).toBe("원 지름의 두 번째 끝점 지정:"); + type("40,0"); + expectBox(entities("Circle")[1], 20, -10, 40, 10); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +/** 폴리선 꼭짓점(소수 셋) */ +const corners = (poly: Poly) => + poly.getEntities().map((part) => { + const point = part.getStartPoint(); + return [Number(point.x.toFixed(3)) + 0, Number(point.y.toFixed(3)) + 0]; + }); + +describe("POLYGON", () => { + it("변 수 <4> · 2.5 · 2 거절 · 외접 숫자 = 아래 변 수평 · 기억 · 한 번 뒤 끝", () => { + type("pol"); + expect(promptLine()).toBe("변의 수 입력 <4>:"); + type("2.5"); + expect(noticeLine()).toBe("정수 값을 요구함."); + type("2"); + expect(noticeLine()).toBe("3에서 1024 사이의 정수를 요구함."); + type("6"); + expect(promptLine()).toBe("폴리곤의 중심 지정 또는 [모서리(E)]:"); + type("0,0"); + expect(promptLine()).toBe("옵션 입력 [원에 내접(I)/원에 외접(C)] :"); + type("c"); + expect(promptLine()).toBe("원의 반지름 지정:"); + type("10"); + expect(activeTool()).toBe(Tool.SELECT); + const hexagon = polys()[0]; + expect(hexagon.getEntities()).toHaveLength(6); + expect(hexagon.getBoundingBox().ymin).toBeCloseTo(-10); // 아래 변 = 반지름 거리 · 수평 + expect(corners(hexagon).filter(([, y]) => y === -10)).toHaveLength(2); + key(" "); + expect(promptLine()).toBe("변의 수 입력 <6>:"); + enter(); + type("100,0"); + expect(promptLine()).toBe("옵션 입력 [원에 내접(I)/원에 외접(C)] :"); + type("i"); + type("10"); + const inscribed = polys()[1]; + expect(corners(inscribed).filter(([, y]) => y === -8.66)).toHaveLength(2); // 내접 · 아래 변 수평(꼭짓점 −60° · −120°) + expect(inscribed.getBoundingBox().xmax).toBeCloseTo(110); + }); + + it("점 = 크기 + 돌림(내접 꼭짓점) · E 모서리 = 왼쪽(반시계)", () => { + type("pol"); + type("4"); + type("0,0"); + enter(); // + type("0,10"); + expect(corners(polys()[0])).toContainEqual([0, 10]); + type("pol"); + enter(); + type("e"); + expect(promptLine()).toBe("모서리의 첫 번째 끝점 지정:"); + type("0,0"); + expect(promptLine()).toBe("모서리의 두 번째 끝점 지정:"); + type("10,0"); + expectBox(polys()[1], 0, 0, 10, 10); + }); +}); + +describe("ARC", () => { + it("세 점 · 한 번 뒤 끝", () => { + type("a"); + expect(promptLine()).toBe("호의 시작점 지정:"); + type("10,0"); + expect(promptLine()).toBe("호의 두 번째 점 지정 또는 [중심(C)/끝(E)]:"); + type("0,10"); + expect(promptLine()).toBe("호의 끝점 지정:"); + type("-10,0"); + expect(activeTool()).toBe(Tool.SELECT); + expectBox(entities("Arc")[0], -10, 0, 10, 10); + }); + + it("C 중심 → 끝점 · A 사이각(− = 시계)", () => { + type("a"); + type("10,0"); + type("c"); + expect(promptLine()).toBe("호의 중심점 지정:"); + type("0,0"); + expect(promptLine()).toBe("호의 끝점 지정 또는 [각도(A)]:"); + type("0,5"); + expectBox(entities("Arc")[0], 0, 0, 10, 10); + type("a"); + type("10,0"); + type("c"); + type("0,0"); + type("a"); + expect(promptLine()).toBe("사이각 지정:"); + type("-90"); + expectBox(entities("Arc")[1], 0, -10, 10, 0); + }); + + it("E 끝 → 중심 · R 반지름 · 시작점 Enter 없음", () => { + type("a"); + enter(); + expect(promptLine()).toBe("호의 시작점 지정:"); + type("10,0"); + type("e"); + expect(promptLine()).toBe("호의 끝점 지정:"); + type("-10,0"); + expect(promptLine()).toBe("호의 중심점 지정 또는 [반지름(R)]:"); + type("0,0"); + expectBox(entities("Arc")[0], -10, 0, 10, 10); + type("a"); + type("10,0"); + type("e"); + type("0,10"); + type("r"); + expect(promptLine()).toBe("호의 반지름 지정:"); + type("10"); + expect(arcShape(entities("Arc")[1]).r).toBeCloseTo(10); + expectXy(arcShape(entities("Arc")[1]).center, 0, 0); + }); +}); + +describe("ELLIPSE", () => { + it("축 두 끝 · 다른 축 거리 숫자 = 중심에서 반 길이(N10) · 호 없음", () => { + type("el"); + expect(promptLine()).toBe("타원의 축 끝점 지정 또는 [중심(C)]:"); + type("a"); + expect(noticeLine()).toBe("「호(A)」 옵션은 이 CAD 에 없음."); + type("0,0"); + expect(promptLine()).toBe("축의 다른 끝점 지정:"); + type("20,0"); + expect(promptLine()).toBe("다른 축으로 거리를 지정 또는 [회전(R)]:"); + move(10, 30); + type("5"); + expect(activeTool()).toBe(Tool.SELECT); + expectBox(polys()[0], 0, -5, 20, 5); + }); + + it("C 중심 · R 회전 60° = 축 × cos", () => { + type("el"); + type("c"); + expect(promptLine()).toBe("타원의 중심 지정:"); + type("0,0"); + expect(promptLine()).toBe("축의 끝점 지정:"); + type("10,0"); + type("r"); + expect(promptLine()).toBe("주축 둘레로 회전 지정:"); + type("60"); + expectBox(polys()[0], -10, -5, 10, 5); + }); +}); + +describe("SPLINE", () => { + it("물음 차례 · U · 넷째 점부터 C(닫고 끝)", () => { + type("spl"); + expect(promptLine()).toBe("첫 번째 점 지정:"); + type("0,0"); + expect(promptLine()).toBe("다음 점 입력:"); + type("10,0"); + expect(promptLine()).toBe("다음 점 입력 또는 [명령 취소(U)]:"); + type("c"); + expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); + type("10,10"); + expect(promptLine()).toBe("다음 점 입력 또는 [명령 취소(U)/닫기(C)]:"); + type("u"); + expect(promptLine()).toBe("다음 점 입력 또는 [명령 취소(U)]:"); + type("10,10"); + type("0,10"); + type("c"); + expect(activeTool()).toBe(Tool.SELECT); + const parts = polys()[0].getEntities(); + expectXy(parts[parts.length - 1].getEndPoint(), 0, 0); + expectXy(parts[0].getStartPoint(), 0, 0); + }); + + it("빈 Enter = 확정 · Esc = 버림", () => { + type("spl"); + type("0,0"); + type("10,0"); + type("20,5"); + enter(); + expect(polys()).toHaveLength(1); + expect(activeTool()).toBe(Tool.SELECT); + type("spl"); + type("0,0"); + type("10,0"); + type("20,5"); + esc(); + expect(polys()).toHaveLength(1); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); diff --git a/resources/tester/cad/keyboard-chain.test.ts b/resources/tester/cad/keyboard-chain.test.ts index eccaba6e8..f7282ffad 100644 --- a/resources/tester/cad/keyboard-chain.test.ts +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -137,19 +137,18 @@ describe("331-1 각도 고정 → 길이", () => { expectXy(lines()[1].end, -10, -0.5); }); - it("PLINE 첫 점에 숫자 · @x,y → 무시 · 도구 살아 있음 · 알림 0", () => { + it("PLINE 첫 점에 숫자 → 거절 · 도구 살아 있음 · 알림 0(첫 점 @x,y = 마지막 점 기준 — prompt-frame)", () => { type(controller, "pline"); type(controller, "100"); - type(controller, "@10,0"); const snapshot = getActiveToolActor()?.getSnapshot(); expect(snapshot?.status).toBe("active"); - expect(snapshot?.value).toBe("STEP_0"); + expect(snapshot?.value).toBe("START"); // 337 나-1 폴리선 손 머신 expect(toast.error).not.toHaveBeenCalled(); - expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 · 상대 좌표 = 거절 + expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 = 거절 type(controller, "0,0"); type(controller, "<0"); type(controller, "100"); - expect(getActiveToolActor()?.getSnapshot()?.context.values).toHaveLength(2); + expect(getActiveToolActor()?.getSnapshot()?.context.segments).toHaveLength(1); }); }); @@ -210,16 +209,15 @@ describe("331-3 선 중 C · U · 한 번 스냅 · 한글 안내", () => { type(controller, "10,10"); }; - it("C = 첫 점으로 닫고 끝 · 안내 「[닫기(C)/명령취소(U)]」", () => { + it("C = 첫 점으로 닫고 끝 · 안내 「[닫기(C)/명령 취소(U)]」", () => { drawOpen(); - expect(getLastStateInstructions()).toBe("다음 점 지정 또는 [닫기(C)/명령취소(U)]:"); + expect(getLastStateInstructions()).toBe("다음 점 지정 또는 [닫기(C)/명령 취소(U)]:"); type(controller, "c"); const drawn = lines(); expect(drawn).toHaveLength(3); expectXy(drawn[2].start, 10, 10); expectXy(drawn[2].end, 0, 0); - expect(getActiveToolActor()?.getSnapshot()?.value).toBe("WAITING_FOR_START_POINT"); - expect(getLastStateInstructions()).toBe("첫 점 지정 (x,y):"); + expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("SELECT"); // 337 — 닫으면 명령 끝 }); it("C 는 둘째 선부터 — 한 구간이면 무시", () => { diff --git a/resources/tester/cad/measure-acad.test.ts b/resources/tester/cad/measure-acad.test.ts new file mode 100644 index 000000000..4cc62edf8 --- /dev/null +++ b/resources/tester/cad/measure-acad.test.ts @@ -0,0 +1,363 @@ +/** + * 337 다-2 — 늘이기 · 재기 · 치수 오토캐드 조작(설계 1-2 · 6-3 다 줄 중 LENGTHEN · DIVIDE · MEASURE · DIST · DIM). + * 물음 글 · 옵션 글자 · 기본값 · 빈 Enter · Esc · 끝난 뒤를 키 · 마우스로 넣어 숫자로 판정. + * 객체는 앱 코드(fromJson)로만 만듦 — 시험 쪽 flatten-js 는 다른 사본. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad measure-acad + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("react-toastify", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }), +})); + +import { getCommandHistory } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command"; +import { getDimDecimals } from "../../../B07_DesignDetail/openwebcad/src/commands/dim-settings"; +import { ArcEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/ArcEntity"; +import { CircleEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/CircleEntity"; +import { EntityName, type Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity"; +import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity"; +import type { MeasurementEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/MeasurementEntity"; +import { getActiveLayerId, setEntities } from "../../../B07_DesignDetail/openwebcad/src/state"; +import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools"; +import { + activeTool, + click, + entities, + initIdle, + key, + noticeLine, + promptLine, + type, + typeEnter, +} from "./helpers/cad-drive"; + +type Xy = { x: number; y: number }; +const enter = () => key("Enter"); +const esc = () => key("Escape"); +const base = () => ({ lineColor: "#fff", lineWidth: 1, layerId: getActiveLayerId() }); + +async function addLine(x1: number, y1: number, x2: number, y2: number): Promise { + const line = await LineEntity.fromJson({ + id: `line-${x1}-${y1}-${x2}-${y2}`, + type: EntityName.Line, + ...base(), + shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } }, + } as never); + setEntities([...entities(), line], true); // 그린 것처럼 되돌림 기록에 올림 + return line; +} + +async function addArc(): Promise { + // 중심 (0, 0) · 반지름 50 · 0° → 90° 반시계(길이 25π) + const arc = await ArcEntity.fromJson({ + id: "arc", + type: EntityName.Arc, + ...base(), + shapeData: { center: { x: 0, y: 0 }, radius: 50, startAngle: 0, endAngle: Math.PI / 2, counterClockwise: true }, + } as never); + setEntities([...entities(), arc], true); // 그린 것처럼 되돌림 기록에 올림 + return arc; +} + +async function addCircle(): Promise { + const circle = await CircleEntity.fromJson({ + id: "circle", + type: EntityName.Circle, + ...base(), + shapeData: { center: { x: 300, y: 300 }, radius: 10 }, + } as never); + setEntities([...entities(), circle], true); // 그린 것처럼 되돌림 기록에 올림 + return circle; +} + +const ends = (entity: Entity): [Xy, Xy] => { + const shape = entity.getShape() as unknown as { start: Xy; end: Xy }; + return [ + { x: shape.start.x, y: shape.start.y }, + { x: shape.end.x, y: shape.end.y }, + ]; +}; +const onlyLine = () => entities("Line")[0]; +const lineLength = () => { + const [a, b] = ends(onlyLine()); + return Math.hypot(b.x - a.x, b.y - a.y); +}; +const points = () => + entities("Point") + .map((entity) => { + const p = entity.getShape() as unknown as Xy; + return { x: Math.round(p.x * 1000) / 1000, y: Math.round(p.y * 1000) / 1000 }; + }) + .sort((a, b) => a.x - b.x); +const dims = () => entities("Measurement") as MeasurementEntity[]; +const lastLog = (n = 1) => getCommandHistory().slice(-n); +/** 선형 치수 글(반올림 · 끝 0 뗌) · 반지름 치수 글(소수 자릿수 그대로) */ +const fmt = (value: number) => String(Number(value.toFixed(getDimDecimals()))); +const fixed = (value: number) => value.toFixed(getDimDecimals()); + +beforeEach(() => { + initIdle(); +}); + +describe("LENGTHEN(LEN)", () => { + const FIRST = "측정할 객체 선택 또는 [증분(DE)/퍼센트(P)/전체(T)]"; + + it("누름 = 현재 길이 · DE → 되풀이 · U = 이 명령 안 바꿈만 · 빈 Enter = 끝", async () => { + await addLine(0, 0, 100, 0); + typeEnter("len"); + expect(activeTool()).toBe(Tool.LENGTHEN); + expect(promptLine()).toBe(`${FIRST} <전체(T)>:`); + click(50, 0); + expect(noticeLine()).toBe("현재 길이: 100.0000"); + expect(promptLine()).toBe(`${FIRST} <전체(T)>:`); + type("d"); // DE 보다 짧음 — 안 맞음 + expect(noticeLine()).toBe("*선택이 잘못됨*"); + type("dy"); + expect(noticeLine()).toBe("「동적(DY)」 옵션은 이 CAD 에 없음."); + type("de"); + expect(promptLine()).toBe("증분 길이 입력 <0.0000>:"); + type("10"); + expect(promptLine()).toBe("변경할 객체 선택 또는 [명령 취소(U)]:"); + click(95, 0); // 끝 쪽 + expect(ends(onlyLine())[1].x).toBeCloseTo(110); + click(105, 0); + expect(ends(onlyLine())[1].x).toBeCloseTo(120); + expect(activeTool()).toBe(Tool.LENGTHEN); // 되풀이 + type("u"); + expect(ends(onlyLine())[1].x).toBeCloseTo(110); + type("u"); + type("u"); // 이 명령 안 바꿈 다 되돌린 뒤 = 아무 일 없음(앞 객체 지키기) + expect(entities("Line")).toHaveLength(1); + expect(lineLength()).toBeCloseTo(100); + click(5, 0); // 시작 쪽 + expect(ends(onlyLine())[0].x).toBeCloseTo(-10); + enter(); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("마지막 방식 · 값 기억 — 첫 물음 빈 Enter = 그 방식 · 값 빈 Enter = 앞 값", async () => { + await addLine(0, 0, 100, 0); + typeEnter("len"); + type("de"); + type("10"); + esc(); + typeEnter("len"); + expect(promptLine()).toBe(`${FIRST} <증분(DE)>:`); + enter(); + expect(promptLine()).toBe("증분 길이 입력 <10.0000>:"); + enter(); + click(95, 0); + expect(lineLength()).toBeCloseTo(110); + esc(); // Esc = 끝 · 바꾼 것 남음 + expect(activeTool()).toBe(Tool.SELECT); + expect(lineLength()).toBeCloseTo(110); + }); + + it("P · T — 퍼센트 · 전체 · 양수 거절 · 호", async () => { + await addLine(0, 0, 100, 0); + typeEnter("len"); + type("p"); + expect(promptLine()).toBe("퍼센트 길이 입력 <100.0000>:"); + type("-5"); + expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); + type("50"); + click(90, 0); + expect(ends(onlyLine())).toEqual([ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + ]); + esc(); + typeEnter("len"); + expect(promptLine()).toBe(`${FIRST} <퍼센트(P)>:`); + type("t"); + expect(promptLine()).toBe("전체 길이 지정 <1.0000>:"); + type("a"); + expect(noticeLine()).toBe("「각도(A)」 옵션은 이 CAD 에 없음."); + type("200"); + click(45, 0); + expect(lineLength()).toBeCloseTo(200); + await addArc(); + click(0, 50); // 호 끝(90°) 쪽 + const arc = entities("Arc")[0].getShape() as unknown as { length: number; endAngle: number }; + expect(arc.length).toBeCloseTo(200); + expect(arc.endAngle).toBeCloseTo(4); // 200 / 50 rad + esc(); + }); +}); + +describe("DIVIDE(DIV) · MEASURE(ME)", () => { + it("DIVIDE — 기본 없음 · 정수 2~32767 · 블록 없음 · 한 번 뒤 끝", async () => { + await addLine(0, 0, 100, 0); + typeEnter("div"); + expect(promptLine()).toBe("등분할 객체 선택:"); + click(50, 0); + expect(promptLine()).toBe("세그먼트 수 입력:"); + enter(); // 기본 없음 — 그대로 + expect(promptLine()).toBe("세그먼트 수 입력:"); + type("2.5"); + expect(noticeLine()).toBe("정수 값을 요구함."); + type("1"); + expect(noticeLine()).toBe("2에서 32767 사이의 정수를 요구함."); + type("b"); + expect(noticeLine()).toBe("「블록(B)」 옵션은 이 CAD 에 없음."); + type("4"); + expect(points()).toEqual([ + { x: 25, y: 0 }, + { x: 50, y: 0 }, + { x: 75, y: 0 }, + ]); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("MEASURE — 누른 자리 가까운 끝부터 · 양수", async () => { + await addLine(0, 0, 100, 0); + typeEnter("me"); + expect(promptLine()).toBe("길이 분할 객체 선택:"); + click(90, 0); // 끝 쪽 + expect(promptLine()).toBe("세그먼트의 길이 지정:"); + type("0"); + expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); + type("30"); + expect(points().map((p) => p.x)).toEqual([10, 40, 70]); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +describe("DIST(DI)", () => { + it("두 점 → 오토캐드 꼴 두 줄 기록 · 한 번 뒤 끝 · 다중 점 없음", () => { + typeEnter("di"); + expect(promptLine()).toBe("첫 번째 점 지정:"); + type("0,0"); + expect(promptLine()).toBe("두 번째 점 지정:"); + type("m"); + expect(noticeLine()).toBe("「다중 점(M)」 옵션은 이 CAD 에 없음."); + type("30,40"); + expect(lastLog(2)).toEqual([ + "거리 = 50.0000, XY 평면에서의 각도 = 53, XY 평면으로부터의 각도 = 0", + "X 증분 = 30.0000, Y 증분 = 40.0000, Z 증분 = 0.0000", + ]); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +describe("DIMLINEAR(DLI) · DIMALIGNED(DAL)", () => { + const PLACE = "치수선의 위치 지정 또는 [문자(T)/수평(H)/수직(V)]:"; + + it("원점 둘 → V 고정 → T 문자(`<>` = 잰 값) → 놓음 · 기록 · 한 번 뒤 끝", () => { + typeEnter("dli"); + expect(promptLine()).toBe("첫 번째 치수보조선 원점 지정 또는 <객체 선택>:"); + type("0,0"); + expect(promptLine()).toBe("두 번째 치수보조선 원점 지정:"); + type("100,30"); + expect(promptLine()).toBe(PLACE); + type("m"); + expect(noticeLine()).toBe("「여러 줄 문자(M)」 옵션은 이 CAD 에 없음."); + type("v"); + expect(promptLine()).toBe(PLACE); + type("t"); + expect(promptLine()).toBe(`치수 문자 입력 <${fmt(30)}>:`); + typeEnter("L=<>"); + expect(promptLine()).toBe(PLACE); + type("120,15"); + const [dim] = dims(); + expect(dim.getLabel()).toBe(`L=${fmt(30)}`); + expect(dim.getEndPoint().x).toBeCloseTo(0); // 수직 — 끝점 x 를 시작점에 맞춤 + expect(dim.getEndPoint().y).toBeCloseTo(30); + expect(lastLog()).toEqual([`치수 문자 = L=${fmt(30)}`]); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("첫 물음 빈 Enter = 객체 선택 → H 고정 · T 빈 Enter = 잰 값 그대로", async () => { + await addLine(0, 0, 80, 60); + typeEnter("dli"); + enter(); + expect(promptLine()).toBe("치수 기입할 객체 선택:"); + click(40, 30); + expect(promptLine()).toBe(PLACE); + type("h"); + type("t"); + expect(promptLine()).toBe(`치수 문자 입력 <${fmt(80)}>:`); + enter(); + type("40,-20"); // 수평 고정이라 옆에 놓아도 가로 + const [dim] = dims(); + expect(dim.getLabel()).toBe(fmt(80)); + expect(dim.textOverride).toBeUndefined(); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("Esc = 끝 · 안 만듦 · DIMALIGNED 는 [문자(T)] 만", () => { + typeEnter("dli"); + type("0,0"); + type("10,0"); + esc(); + expect(activeTool()).toBe(Tool.SELECT); + expect(dims()).toHaveLength(0); + typeEnter("dal"); + type("0,0"); + type("30,40"); + expect(promptLine()).toBe("치수선의 위치 지정 또는 [문자(T)]:"); + type("h"); + expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); + type("0,50"); + expect(dims()[0].getLabel()).toBe(fmt(50)); + }); +}); + +describe("DIMCONTINUE(DCO) · DIMBASELINE(DBA)", () => { + const CHAIN = "두 번째 치수보조선 원점 지정 또는 [명령 취소(U)]:"; + + it("되풀이 · U = 이 명령 안 마지막 치수 · 빈 Enter = 끝", () => { + typeEnter("dli"); + type("0,0"); + type("100,0"); + type("50,20"); + expect(dims()).toHaveLength(1); + typeEnter("dco"); + expect(promptLine()).toBe(CHAIN); + type("s"); + expect(noticeLine()).toBe("「선택(S)」 옵션은 이 CAD 에 없음."); + type("150,0"); + type("200,0"); + expect(dims()).toHaveLength(3); + expect(dims()[2].getStartPoint().x).toBeCloseTo(150); // 앞 치수 끝에서 이어짐 + expect(promptLine()).toBe(CHAIN); + type("u"); + expect(dims()).toHaveLength(2); + type("u"); + type("u"); // 이 명령 밖 치수는 안 지움 + expect(dims()).toHaveLength(1); + type("130,0"); + expect(dims()[1].getStartPoint().x).toBeCloseTo(100); // U 뒤 앞 치수에서 다시 이음 + enter(); + expect(activeTool()).toBe(Tool.SELECT); + typeEnter("dba"); + expect(promptLine()).toBe(CHAIN); + type("180,0"); + expect(dims()).toHaveLength(3); + expect(dims()[2].getStartPoint().x).toBeCloseTo(100); // 기준선 = 앞 치수 시작점 + esc(); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +describe("DIMRADIUS(DRA) · DIMDIAMETER(DDI)", () => { + it("호 또는 원 → [문자(T)] → 위치 · 기록 · 한 번 뒤 끝", async () => { + await addCircle(); + typeEnter("dra"); + expect(promptLine()).toBe("호 또는 원 선택:"); + click(310, 300); + expect(promptLine()).toBe("치수선의 위치 지정 또는 [문자(T)]:"); + type("a"); + expect(noticeLine()).toBe("「각도(A)」 옵션은 이 CAD 에 없음."); + type("t"); + expect(promptLine()).toBe(`치수 문자 입력 :`); + typeEnter("<> BASE"); + type("330,300"); + expect(lastLog()).toEqual([`치수 문자 = R${fixed(10)} BASE`]); + expect(activeTool()).toBe(Tool.SELECT); + typeEnter("ddi"); + click(310, 300); + type("330,300"); + expect(lastLog()).toEqual([`치수 문자 = Ø${fixed(20)}`]); + }); +}); diff --git a/resources/tester/cad/modify-acad.test.ts b/resources/tester/cad/modify-acad.test.ts new file mode 100644 index 000000000..844fafe23 --- /dev/null +++ b/resources/tester/cad/modify-acad.test.ts @@ -0,0 +1,374 @@ +/** + * 337 다-1 고치기 7 — OFFSET · FILLET · CHAMFER · TRIM · EXTEND · BREAK · JOIN 을 오토캐드 물음 차례 · 옵션 · 기본값 · + * 빈 Enter · Esc · 끝난 뒤 동작으로(설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-2 · 6-3 다 줄). + * 기억(OFFSETDIST · FILLETRAD · CHAMFERA/B · TRIMMODE)은 파일 안 세션 하나 — 시험 차례대로 이어짐. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad modify-acad + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("react-toastify", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }), +})); + +import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools"; +import { activeTool, click, entities, initIdle, key, promptLine, type } from "./helpers/cad-drive"; + +type Shape = { start: { x: number; y: number }; end: { x: number; y: number } }; +const enter = () => key("Enter"); +const r = (value: number) => Math.round(value * 1e4) / 1e4; +/** 선 하나 — 쉼에서 그리고 Esc 로 끝 */ +const line = (a: string, b: string) => { + type("l"); + type(a); + type(b); + key("Escape"); +}; +/** 선마다 [x1, y1, x2, y2] — 차례 무관(정렬) */ +const segs = () => + entities("Line") + .map((entity) => { + const { start, end } = entity.getShape() as unknown as Shape; + return [r(start.x), r(start.y), r(end.x), r(end.y)]; + }) + .sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3]); +const ys = () => + segs() + .map((s) => s[1]) + .sort((a, b) => a - b); + +beforeEach(() => { + initIdle(); +}); + +describe("OFFSET", () => { + it("처음 <통과점> · 통과점 · 되풀이 · 거리 기억 · T · 빈 Enter 끝", () => { + line("0,0", "100,0"); + type("o"); + expect(activeTool()).toBe(Tool.OFFSET); + expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:"); + enter(); // <통과점> + expect(promptLine()).toBe("간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:"); + click(50, 0); + expect(promptLine()).toBe("통과점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:"); + type("30,20"); + expect(ys()).toEqual([0, 20]); + expect(promptLine()).toBe("간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:"); // 되풀이 + key("Escape"); + expect(activeTool()).toBe(Tool.SELECT); + + type("o"); + expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:"); // 통과점 기억 + type("0"); + expect(activeTool()).toBe(Tool.OFFSET); // 0 거절 + type("5"); + click(50, 0); + expect(promptLine()).toBe( + "간격띄우기할 면의 점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:", + ); + type("50,-10"); + click(50, 20); + type("50,30"); + expect(ys()).toEqual([-5, 0, 20, 25]); + enter(); // <종료> + expect(activeTool()).toBe(Tool.SELECT); + + type("o"); + expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <5.0000>:"); + type("t"); + click(50, 25); + expect(promptLine()).toBe("통과점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:"); + type("e"); // 종료 + expect(activeTool()).toBe(Tool.SELECT); + type("o"); + expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:"); + key("Escape"); + }); + + it("지우기(E) 예 · 다중(M) · 명령 취소(U) · 없는 옵션 L 안내", () => { + line("0,0", "100,0"); + type("o"); + type("l"); + expect(activeTool()).toBe(Tool.OFFSET); // 도면층(L) = 이 CAD 에 없음 + type("e"); + expect(promptLine()).toBe( + "간격띄우기 후 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:", + ); + type("y"); + type("10"); + click(50, 0); + type("50,5"); + expect(ys()).toEqual([10]); // 원본 지움 + key("Escape"); + + type("o"); + type("e"); + expect(promptLine()).toBe("간격띄우기 후 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <예>:"); + type("n"); + enter(); // <10.0000> + click(50, 10); + type("m"); + expect(promptLine()).toBe("간격띄우기할 면의 점 지정 또는 [종료(E)/명령 취소(U)] <다음 객체>:"); + type("50,100"); + type("50,100"); + expect(ys()).toEqual([10, 20, 30]); // 방금 만든 것에서 되풀이 · 원본 둠 + type("u"); + expect(ys()).toEqual([10, 20]); + expect(promptLine()).toBe("간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:"); + type("u"); + type("u"); // 이 명령에서 만든 것 없음 = 그대로 + expect(ys()).toEqual([10]); + enter(); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +/** ㄱ 자 — 가로 (0,0)~(100,0) · 세로 (80,−20)~(80,100) · 구석 (80, 0) */ +const corner = () => { + line("0,0", "100,0"); + line("80,-20", "80,100"); +}; + +describe("FILLET", () => { + it("물음 글 · R 기억 · 쌍 하나 뒤 끝", () => { + corner(); + type("f"); + expect(promptLine()).toBe("첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:"); + type("r"); + expect(promptLine()).toBe("모깎기 반지름 지정 <0.0000>:"); + type("10"); + expect(promptLine()).toBe("첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:"); + click(30, 0); + expect(promptLine()).toBe( + "두 번째 객체 선택 또는 Shift 키를 누른 채 선택하여 구석 적용 또는 [반지름(R)]:", + ); + click(80, 60); + expect(segs()).toEqual([ + [0, 0, 70, 0], + [80, 100, 80, 10], + ]); + expect(entities("Arc")).toHaveLength(1); + expect(activeTool()).toBe(Tool.SELECT); + + type("f"); + type("r"); + expect(promptLine()).toBe("모깎기 반지름 지정 <10.0000>:"); + key("Escape"); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("다중(M) · Shift = 반지름 0 구석 · 명령 취소(U) · 자르기(T) 자르지 않기 · 빈 Enter 끝", () => { + corner(); + const before = segs(); + type("f"); + type("m"); + click(30, 0); + click(80, 60, { shiftKey: true }); + expect(segs()).toEqual([ + [0, 0, 80, 0], + [80, 100, 80, 0], + ]); + expect(entities("Arc")).toHaveLength(0); + expect(promptLine()).toBe("첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:"); // 다중 되풀이 + type("u"); + expect(segs()).toEqual(before); + type("t"); + expect(promptLine()).toBe("자르기 모드 옵션 입력 [자르기(T)/자르지 않기(N)] <자르기>:"); + type("n"); + click(30, 0); + click(80, 60); + expect(segs()).toEqual(before); // 안 자름 + expect(entities("Arc")).toHaveLength(1); + enter(); + expect(activeTool()).toBe(Tool.SELECT); + + type("f"); + type("t"); + expect(promptLine()).toBe("자르기 모드 옵션 입력 [자르기(T)/자르지 않기(N)] <자르지 않기>:"); + type("t"); + key("Escape"); + }); +}); + +describe("CHAMFER", () => { + it("D 기억(둘째 기본 = 첫째) · 모따기 선 · 한 번 뒤 끝", () => { + corner(); + type("cha"); + expect(activeTool()).toBe(Tool.CHAMFER); + expect(promptLine()).toBe("첫 번째 선 선택 또는 [명령 취소(U)/거리(D)/자르기(T)/다중(M)]:"); + type("d"); + expect(promptLine()).toBe("첫 번째 모따기 거리 지정 <0.0000>:"); + type("10"); + expect(promptLine()).toBe("두 번째 모따기 거리 지정 <10.0000>:"); + enter(); + click(30, 0); + expect(promptLine()).toBe( + "두 번째 선 선택 또는 Shift 키를 누른 채 선택하여 구석 적용 또는 [거리(D)]:", + ); + click(80, 60); + expect(segs()).toEqual([ + [0, 0, 70, 0], + [70, 0, 80, 10], + [80, 100, 80, 10], + ]); + expect(activeTool()).toBe(Tool.SELECT); + + type("cha"); + type("d"); + expect(promptLine()).toBe("첫 번째 모따기 거리 지정 <10.0000>:"); + type("4"); + expect(promptLine()).toBe("두 번째 모따기 거리 지정 <4.0000>:"); + key("Escape"); + }); + + it("Shift = 거리 0 구석", () => { + corner(); + type("cha"); + click(30, 0); + click(80, 60, { shiftKey: true }); + expect(segs()).toEqual([ + [0, 0, 80, 0], + [80, 100, 80, 0], + ]); + }); +}); + +describe("TRIM", () => { + /** 가로 (0,0)~(100,0) · 세로 둘 x = 30 · 70 */ + const fence = () => { + line("0,0", "100,0"); + line("30,-20", "30,20"); + line("70,-20", "70,20"); + }; + + it("되풀이 · 반경 밖 무시 · U · 지우기(R) · 빈 Enter 끝", () => { + fence(); + type("tr"); + expect(activeTool()).toBe(Tool.ERASER); + expect(promptLine()).toBe( + "자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장 또는 [지우기(R)]:", + ); + click(50, 0); + expect(segs()).toContainEqual([0, 0, 30, 0]); + expect(segs()).toContainEqual([70, 0, 100, 0]); + expect(segs()).toHaveLength(4); + click(50, 50); // 집기 반경 밖 + expect(segs()).toHaveLength(4); + type("u"); + expect(segs()).toContainEqual([0, 0, 100, 0]); + click(50, 0); + click(30, 15); + expect(segs()).toContainEqual([30, -20, 30, 0]); + expect(activeTool()).toBe(Tool.ERASER); // 되풀이 + type("r"); + expect(promptLine()).toBe("지울 객체 선택:"); + click(70, 10); + enter(); + expect(segs().some((s) => s[0] === 70 && s[2] === 70)).toBe(false); + expect(promptLine()).toBe( + "자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장 또는 [지우기(R)]:", + ); + type("c"); + expect(activeTool()).toBe(Tool.ERASER); // 걸치기(C) = 이 CAD 에 없음 + enter(); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("Esc 한 번 = 끝 · Shift = 연장", () => { + line("0,0", "50,0"); + line("80,-20", "80,20"); + type("tr"); + click(40, 0, { shiftKey: true }); + expect(segs()).toContainEqual([0, 0, 80, 0]); + key("Escape"); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("사각형 변 = 원래 도면층 · 꼴(N9)", () => { + type("rec"); + type("0,0"); + type("100,40"); + key("Escape"); + const rectangle = entities("Rectangle")[0]; + rectangle.layerId = "L2"; + rectangle.lineColor = "#ff0000"; + line("20,-10", "20,50"); + type("tr"); + click(60, 40); + const sides = entities("Line").filter((entity) => entity.layerId === "L2"); + expect(sides.length).toBe(4); + expect(sides.every((entity) => entity.lineColor === "#ff0000")).toBe(true); + expect(segs()).toContainEqual([0, 40, 20, 40]); + }); +}); + +describe("EXTEND", () => { + it("빠른 연장 · 되풀이 · Shift = 자르기 · 빈 Enter 끝", () => { + line("0,0", "50,0"); + line("80,-20", "80,20"); + type("ex"); + expect(activeTool()).toBe(Tool.EXTEND); + expect(promptLine()).toBe("연장할 객체 선택 또는 Shift 키를 누른 채 선택하여 자르기:"); + click(40, 0); + expect(segs()).toContainEqual([0, 0, 80, 0]); + click(80, 10, { shiftKey: true }); + expect(segs()).toContainEqual([80, -20, 80, 0]); + expect(activeTool()).toBe(Tool.EXTEND); + enter(); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +describe("BREAK", () => { + it("누른 점 = 첫 끊기점 · 둘째 점 · 한 번 뒤 끝", () => { + line("0,0", "100,0"); + type("br"); + expect(promptLine()).toBe("객체 선택:"); + click(20, 0); + expect(promptLine()).toBe("두 번째 끊기점 지정 또는 [첫 번째 점(F)]:"); + type("60,0"); + expect(segs()).toEqual([ + [0, 0, 20, 0], + [60, 0, 100, 0], + ]); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("첫 번째 점(F) · `@0,0` = 같은 점(틈 없이 둘)", () => { + line("0,0", "100,0"); + type("br"); + click(50, 0); + type("f"); + expect(promptLine()).toBe("첫 번째 끊기점 지정:"); + type("10,0"); + expect(promptLine()).toBe("두 번째 끊기점 지정:"); + type("30,0"); + expect(segs()).toEqual([ + [0, 0, 10, 0], + [30, 0, 100, 0], + ]); + + type("br"); + click(60, 0); + type("@0,0"); + expect(segs()).toEqual([ + [0, 0, 10, 0], + [30, 0, 60, 0], + [60, 0, 100, 0], + ]); + }); +}); + +describe("JOIN", () => { + it("고르기 물음 글 · 빈 Enter = 결합 · 한 번 뒤 끝", () => { + line("0,0", "40,0"); + line("40,0", "40,40"); + type("j"); + expect(promptLine()).toBe("한 번에 결합할 원본 객체 또는 여러 객체 선택:"); + click(20, 0); + click(40, 20); + enter(); + expect(entities("Line")).toHaveLength(0); + expect(entities("PolyLine")).toHaveLength(1); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); diff --git a/resources/tester/cad/mouse-acad.test.ts b/resources/tester/cad/mouse-acad.test.ts index d901f1d05..64d2f25c3 100644 --- a/resources/tester/cad/mouse-acad.test.ts +++ b/resources/tester/cad/mouse-acad.test.ts @@ -1,7 +1,8 @@ /** * CAD 마우스 · 고르기 · 그립 · 직교 · 휠 — 오토캐드 조작(PLAN 337-2 마 · 요구 229-3 · 설계 4 장 · 6-3 마). * 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · N7 · 그립 누름-뗌 대기 · 끌어 놓음 · 그립 숫자 · - * H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂. + * H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂 · + * 마-2: 우클릭(쉼 반복 · 명령 중 Enter) · ZOOM(E · A · W · P · 2X · 'Z) · Shift+우클릭 스냅 메뉴 · 고르기 ALL/L/P · 그립 X · Esc. * 시험 쪽 flatten-js 는 다른 사본 — 점은 앱 코드(LINE 도구)로만 만들고 숫자로 판정. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad mouse-acad */ @@ -14,6 +15,17 @@ vi.mock("react-toastify", () => ({ import { Actor } from "xstate"; import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller"; import { zoomPrevious } from "../../../B07_DesignDetail/openwebcad/src/inputController/zoom-prompt"; +import { + isSnapMenuOpen, + pickSnapMenu, +} from "../../../B07_DesignDetail/openwebcad/src/inputController/snap-menu"; +import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override"; +import { selectingActor } from "../../../B07_DesignDetail/openwebcad/src/inputController/selecting"; +import { + currentNotice, + currentPrompt, +} from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input"; +import { rememberSelection } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt"; import { wheelZoom } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-wheel"; import { getActiveToolActor, @@ -66,6 +78,11 @@ const release = (x: number, y: number, mods?: Mods) => { move(x, y, mods); controller.handleMouseUp(ev(x, y, mods)); }; +/** 가운데 단추 누름 + 뗌(뗌 = 팬 끝) */ +const middle = (x: number, y: number) => { + press(x, y, { button: 1 }); + controller.handleMouseUp(ev(x, y, { button: 1 })); +}; const click = (x: number, y: number, mods?: Mods) => { press(x, y, mods); release(x, y, mods); @@ -85,6 +102,8 @@ const key = (k: string) => preventDefault: vi.fn(), stopPropagation: vi.fn(), } as unknown as KeyboardEvent); +const rightClick = (shift = false) => + controller.handleMouseUp(ev(0, 0, { button: 2, shiftKey: shift })); const typeEnter = (text: string) => { for (const ch of text) key(ch); key("Enter"); @@ -184,6 +203,20 @@ describe("그립 — 누르고-옮기고-누르기 · 끌기 · 숫자 · N7 · expect(toolType()).toBe(Tool.SELECT); }); + it("뜨거운 그립 Esc · 종료(X) = 놓음(안 바뀜) · 물음 줄", () => { + click(50, 0); + click(100, 0); + expect(currentPrompt().line).toBe("** 신축 ** 신축점 지정 또는 [종료(X)]:"); + move(150, 90); + key("Escape"); + expect(toolType()).toBe(Tool.SELECT); + expectXy(segOf(a).end, 100, 0); + click(100, 0); // 고름은 남음 — 다시 그립 + typeEnter("x"); + expect(toolType()).toBe(Tool.SELECT); + expectXy(segOf(a).end, 100, 0); + }); + it("그립 끌어 놓음 = 놓은 자리", () => { click(50, 0); press(100, 0); @@ -292,17 +325,128 @@ describe("휠", () => { view.zoomToFitScreen = vi.fn(() => view.setScreenScale(3)); const now = vi.spyOn(Date, "now"); now.mockReturnValue(1000); - press(10, 10, { button: 1 }); + middle(10, 10); now.mockReturnValue(1300); - press(11, 11, { button: 1 }); + middle(11, 11); expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1); expect(view.getScreenScale()).toBe(3); expect(zoomPrevious()).toBe(true); expect(view.getScreenScale()).toBe(1); now.mockReturnValue(5000); - press(10, 10, { button: 1 }); + middle(10, 10); now.mockReturnValue(5600); - press(10, 10, { button: 1 }); + middle(10, 10); expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1); }); }); + +describe("우클릭 · 고르기 키워드(마-2)", () => { + it("우클릭 = 쉴 때 앞 명령 반복 · 명령 중 Enter(고르기 끝)", () => { + typeEnter("DRAWORDER"); + expect(toolType()).toBe(Tool.DRAWORDER_FRONT); + key("Escape"); + expect(toolType()).toBe(Tool.SELECT); + rightClick(); + expect(toolType()).toBe(Tool.DRAWORDER_FRONT); + expect(selectingActor()).not.toBeNull(); + click(50, 0); + rightClick(); // 고르기 끝 → 명령 확정 + expect(selectingActor()?.getSnapshot()?.context?.type).toBe(Tool.SELECT); + expect(toolType()).toBe(Tool.SELECT); // 쉼으로 + }); + + it("고르기 ALL · L(최종) · P(이전) — 앞 고름에 더함 · 「n개를 찾음」", () => { + typeEnter("DRAWORDER"); + typeEnter("all"); + expect(getSelectedEntityIds().sort()).toEqual([a, b].sort()); + expect(currentNotice()).toBe("2개를 찾음, 총 2개"); + key("Escape"); + setSelectedEntityIds([]); + typeEnter("DRAWORDER"); + typeEnter("L"); + expect(getSelectedEntityIds()).toEqual([b]); + key("Escape"); + setSelectedEntityIds([]); + rememberSelection([a]); + typeEnter("DRAWORDER"); + typeEnter("p"); + expect(getSelectedEntityIds()).toEqual([a]); + }); +}); + +describe("ZOOM 덧물음 · 스냅 메뉴(마-2)", () => { + const view = () => + getScreenCanvasDrawController() as unknown as { + zoomToFitScreen: () => void; + setScreenScale: (s: number) => void; + getScreenScale: () => number; + getCanvasSize: () => Xy; + }; + + it("Z 물음 줄 · E = 범위 · P = 앞 화면 · Enter = 실시간 안내", () => { + view().zoomToFitScreen = vi.fn(() => view().setScreenScale(4)); + typeEnter("z"); + expect(currentPrompt().line).toBe( + "윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:", + ); + typeEnter("e"); + expect(view().getScreenScale()).toBe(4); + expect(toolType()).toBe(Tool.SELECT); + typeEnter("Z"); + typeEnter("P"); + expect(view().getScreenScale()).toBe(1); + typeEnter("Z"); + typeEnter("C"); + expect(currentNotice()).toBe("「중심(C)」 옵션은 이 CAD 에 없음."); + key("Enter"); + expect(currentNotice()).toBe("실시간 줌은 휠로."); + expect(currentPrompt().kind).toBe("idle"); + }); + + it("2X = 배율 두 배(가운데 붙잡음) · W = 두 구석 · 누름 = 바로 윈도우", () => { + typeEnter("Z"); + typeEnter("2x"); + expect(view().getScreenScale()).toBe(2); + expect(zoomPrevious()).toBe(true); // 배율 · 원점 되돌림 + const size = view().getCanvasSize(); + typeEnter("Z"); + typeEnter("W"); + expect(currentPrompt().line).toBe("첫 번째 구석 지정:"); + click(0, 0); + expect(currentPrompt().line).toBe("반대 구석 지정:"); + click(100, 50); + expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 100, size.y / 50), 6); + expect(getSelectedEntityIds()).toEqual([]); // 고르기로 안 샘 + zoomPrevious(); + typeEnter("Z"); + click(0, 0); + click(50, 100); + expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 50, size.y / 100), 6); + zoomPrevious(); + }); + + it("'Z 투명 — 선 그리는 중 2X 뒤 선 이어 감", () => { + lineTool(); + click(300, 300); + typeEnter("'z"); + typeEnter("2X"); + expect(view().getScreenScale()).toBe(2); + expect(toolType()).toBe(Tool.LINE); + zoomPrevious(); + }); + + it("Shift+우클릭 = 점 물음에서만 스냅 메뉴 → 한 번 스냅", () => { + rightClick(true); // 쉼 — 메뉴 없음(앞 명령 반복) + expect(isSnapMenuOpen()).toBe(false); + key("Escape"); // 반복된 명령 끝 + lineTool(); + click(300, 300); + rightClick(true); + expect(isSnapMenuOpen()).toBe(true); + pickSnapMenu("END"); + expect(isSnapMenuOpen()).toBe(false); + expect(getSnapOverride()).toBe("END"); + click(96, 4); + expectXy(lastSeg().end, 100, 0); + }); +}); diff --git a/resources/tester/cad/prompt-frame.test.ts b/resources/tester/cad/prompt-frame.test.ts index dfeeabc1a..986366abc 100644 --- a/resources/tester/cad/prompt-frame.test.ts +++ b/resources/tester/cad/prompt-frame.test.ts @@ -1,5 +1,5 @@ /** - * 337 가-1 옵션 틀 — 물음 꼴 · 옵션 맞추기 · 친 글 사다리 · 거절 · Esc · 빈 Enter · 우클릭 · 명령 끝 · 엔진 v2 · 기억 · 덧물음 · Ctrl. + * 337 가-1 · 가-2 옵션 틀 — 물음 꼴 · 옵션 맞추기 · 친 글 사다리 · 거절 · Esc · 빈 Enter · 우클릭 · 명령 끝 · 엔진 v2 · 기억 · 덧물음 · Ctrl. * 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 2 장 · 6-3 가 줄. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad prompt-frame */ @@ -35,6 +35,9 @@ import { setActiveToolActor, setSelectedEntityIds, } from "../../../B07_DesignDetail/openwebcad/src/state"; +import { commandCandidate } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-history"; +import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override"; +import { getScreenCanvasDrawController } from "../../../B07_DesignDetail/openwebcad/src/state"; import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools"; import { createSequenceTool } from "../../../B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool"; import { @@ -556,3 +559,141 @@ describe("Ctrl · Alt 글자(H1)", () => { expect(activeTool()).toBe(Tool.LINE); }); }); + +// ---- 가-2 — ↑↓ · Tab · FROM · M2P · @ · # · 스냅 이름 · H11 ---- + +const segs = () => + (entities("Line") as unknown as Array<{ segment: { start: Xy; end: Xy } }>).map( + (line) => line.segment, + ); +const expectSeg = (seg: { start: Xy; end: Xy }, x1: number, y1: number, x2: number, y2: number) => { + expect( + [seg.start.x, seg.start.y, seg.end.x, seg.end.y].map((v) => Math.round(v * 1000) / 1000), + ).toEqual([x1, y1, x2, y2]); +}; + +describe("가-2 ↑↓ 기록 · 최근 값 · 후보(2-12 · H11)", () => { + it("쉴 때 ↑↓ = 친 명령만(알 수 없는 글 · 알림 줄 안 섞임) · 끝을 지나면 빈 글", () => { + type("l"); + key("Escape"); + type("zz"); // 알 수 없는 명령 — 기록 안 됨 + type("circle"); + key("Escape"); + key("ArrowUp"); + expect(inputText()).toBe("CIRCLE"); + key("ArrowUp"); + expect(inputText()).toBe("L"); + key("ArrowDown"); + expect(inputText()).toBe("CIRCLE"); + key("ArrowDown"); + expect(inputText()).toBe(""); + }); + + it("명령 중 ↑ = 그 물음 최근 값", () => { + start(polygonLike); + type("7"); + key("Escape"); + start(polygonLike); + key("ArrowUp"); + expect(inputText()).toBe("7"); + key("Escape"); + }); + + it("Tab · ↑↓ = 후보 돌림(처음 친 글 기준) · Shift+↑ = 화면 옮김", () => { + typed("ci"); + key("Tab"); + expect(inputText()).toBe(commandCandidate("CI", 0)); + key("Tab"); + expect(inputText()).toBe(commandCandidate("CI", 1)); + key("ArrowUp"); + expect(inputText()).toBe(commandCandidate("CI", 0)); + key("Escape"); + const before = getScreenCanvasDrawController().getScreenOffset().y; + key("ArrowUp", { shiftKey: true }); + expect(getScreenCanvasDrawController().getScreenOffset().y).not.toBe(before); + key("ArrowDown", { shiftKey: true }); + }); +}); + +describe("가-2 점 물음 보조(1-4)", () => { + it("FROM — 기준점 · <간격띄우기> @dx,dy → 그 점이 원래 물음에", () => { + type("l"); + type("from"); + expect(promptLine()).toBe("기준점:"); + type("10,10"); + expect(promptLine()).toBe("<간격띄우기>:"); + type("@5,0"); + type("@10,0"); + expectSeg(segs()[0], 15, 10, 25, 10); + }); + + it("M2P — 두 점의 가운데", () => { + type("l"); + type("m2p"); + expect(promptLine()).toBe("중간점의 첫 번째 점:"); + type("0,0"); + expect(promptLine()).toBe("중간점의 두 번째 점:"); + type("10,0"); + type("@0,5"); + expectSeg(segs()[0], 5, 0, 5, 5); + }); + + it("@ = 마지막 점 · 기준점 없는 첫 점 @dx,dy = 마지막 점 기준 · # = 절대", () => { + type("l"); + type("0,0"); + type("10,0"); + key("Escape"); + type("l"); + type("@"); + type("@0,10"); + expectSeg(segs()[1], 10, 0, 10, 10); + key("Escape"); + type("l"); + type("@1,1"); // 마지막 점 (10, 10) 기준 + type("#0,0"); + expectSeg(segs()[2], 11, 11, 0, 0); + }); + + it("스냅 이름 — 세 글자~영문 전체 · QUA · NOD · 없는 스냅 안내 · 두 글자는 아님", () => { + type("l"); + for (const [text, snap] of [ + ["endp", "END"], + ["endpoint", "END"], + ["qua", "QUA"], + ["node", "NOD"], + ["_non", "NON"], + ]) { + type(text); + expect(getSnapOverride()).toBe(snap); + } + type("tangent"); + expect(noticeLine()).toBe("「TAN」 스냅은 이 CAD 에 없음."); + type("ext"); + expect(noticeLine()).toBe("「EXT」 스냅은 이 CAD 에 없음."); + expect(activeTool()).toBe(Tool.LINE); // EXT · INS 가 명령으로 안 돎(N14) + key("Escape"); + expect(getSnapOverride()).toBeNull(); + }); + + it("옵션 영문 전체 = 옵션 · 줄임 CEN = 스냅", () => { + const seen: string[] = []; + const tool = createSequenceTool({ + tool: Tool.DIST, + steps: [ + { + kind: "point", + prompt: { text: "호의 두 번째 점 지정", options: [opt("CENTER", "C", "중심")] }, + options: { CENTER: { run: () => seen.push("CENTER") } }, + }, + ], + commit: () => {}, + }); + start(tool); + type("cen"); + expect(getSnapOverride()).toBe("CEN"); + expect(seen).toEqual([]); + type("center"); + expect(seen).toEqual(["CENTER"]); + key("Escape"); + }); +}); diff --git a/resources/tester/cad/text-acad.test.ts b/resources/tester/cad/text-acad.test.ts new file mode 100644 index 000000000..aa8de4942 --- /dev/null +++ b/resources/tester/cad/text-acad.test.ts @@ -0,0 +1,319 @@ +/** + * 337 나-2 — 구성선 · 글자 · 해치 넷(XLINE · TEXT · MTEXT · HATCH)을 오토캐드 물음 차례대로. + * 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-1 · 6-3 나 줄(XLINE 뒤 넷). + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-acad + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("react-toastify", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }), +})); + +import { remember } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt"; +import { cancelActiveCommand } from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input"; +import { getSelectedEntityIds } from "../../../B07_DesignDetail/openwebcad/src/state"; +import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools"; +import { + activeTool, + click, + entities, + initIdle, + key, + noticeLine, + promptLine, + type, + typeEnter, + typed, +} from "./helpers/cad-drive"; + +type Xy = { x: number; y: number }; +// biome-ignore lint/suspicious/noExplicitAny: 앱 객체 속을 숫자로만 읽음 +type Any = any; +const enter = () => key("Enter"); +const close = (a: number, b: number) => expect(a).toBeCloseTo(b, 6); +const ends = (line: Any): [Xy, Xy] => [line.getStartPoint(), line.getEndPoint()]; +const texts = () => + entities("Text").map((text: Any) => ({ + label: text.getLabel() as string, + at: text.basePoint as Xy, + size: text.getTextOptions().fontSize as number, + dir: text.getTextOptions().textDirection as Xy, + })); +const idle = () => expect(activeTool()).toBe(Tool.SELECT); + +beforeEach(() => { + initIdle(); + remember("XLINE.ANGLE", 0); + remember("TEXTSIZE", 2.5); + remember("TEXT.ANGLE", 0); +}); + +describe("XLINE(XL)", () => { + it("첫 물음 · H 되풀이 · Enter 끝", () => { + type("xl"); + expect(promptLine()).toBe("점 지정 또는 [수평(H)/수직(V)/각도(A)]:"); + type("h"); + expect(promptLine()).toBe("통과점을 지정:"); + type("0,10"); + type("0,20"); + expect(promptLine()).toBe("통과점을 지정:"); + enter(); + idle(); + const lines = entities("Line").map(ends); + expect(lines).toHaveLength(2); + lines.forEach(([a, b], index) => { + close(a.y, 10 * (index + 1)); + close(b.y, 10 * (index + 1)); + expect(Math.abs(b.x - a.x)).toBeGreaterThan(1000); + }); + }); + + it("V · Esc = 끝(그은 선 남음)", () => { + type("xl"); + type("v"); + type("5,0"); + key("Escape"); + idle(); + const [[a, b]] = entities("Line").map(ends); + close(a.x, 5); + close(b.x, 5); + }); + + it("A — 각도 물음 · 기억 · 통과점", () => { + type("xl"); + type("a"); + expect(promptLine()).toBe("X선의 각도 입력 <0>:"); + type("45"); + expect(promptLine()).toBe("통과점을 지정:"); + type("0,0"); + enter(); + type("xl"); + type("a"); + expect(promptLine()).toBe("X선의 각도 입력 <45>:"); + enter(); // 45 + type("10,0"); + enter(); + idle(); + const lines = entities("Line").map(ends); + expect(lines).toHaveLength(2); + for (const [a, b] of lines) close((b.y - a.y) / (b.x - a.x), 1); + const [, [a2]] = lines; + close(a2.y - 0, a2.x - 10); // (10, 0) 을 지남 + }); + + it("두 점 방향 되풀이 · 없는 옵션 안내 · 첫 통과점 Enter = 그냥 끝", () => { + type("xl"); + type("b"); + expect(noticeLine()).toBe("「이등분(B)」 옵션은 이 CAD 에 없음."); + type("0,0"); + type("10,10"); + type("10,-10"); + enter(); + idle(); + const slopes = entities("Line") + .map(ends) + .map(([a, b]) => (b.y - a.y) / (b.x - a.x)); + expect(slopes).toHaveLength(2); + close(slopes[0], 1); + close(slopes[1], -1); + type("xl"); + type("h"); + enter(); + idle(); + expect(entities("Line")).toHaveLength(2); + }); +}); + +describe("TEXT(DT)", () => { + it("시작점 → 높이 → 각 → 줄 이어 치기 → 빈 줄 끝", () => { + type("dt"); + expect(promptLine()).toBe("문자의 시작점 지정:"); + type("s"); + expect(noticeLine()).toBe("「스타일(S)」 옵션은 이 CAD 에 없음."); + type("0,0"); + expect(promptLine()).toBe("높이 지정 <2.5000>:"); + type("-2"); + expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); + type("5"); + expect(promptLine()).toBe("문자의 회전 각도 지정 <0>:"); + enter(); + expect(promptLine()).toBe("문자 입력:"); + typeEnter("ab c"); // 글 물음의 Space = 빈칸 + expect(promptLine()).toBe("문자 입력:"); + typeEnter("de"); + enter(); // 빈 줄 = 끝 + idle(); + const [first, second] = texts(); + expect([first.label, second.label]).toEqual(["ab c", "de"]); + expect([first.size, second.size]).toEqual([5, 5]); + close(first.at.x, 0); + close(first.at.y, 0); + close(second.at.x, 0); + close(second.at.y, -5 * 1.35); + }); + + it("높이 기억 · 시작점 Enter = 앞 글 아래(높이 · 각 안 물음) · 처음 줄 빈 Enter = 아무것도 없이 끝", () => { + type("dt"); + type("0,0"); + type("4"); + enter(); + typeEnter("one"); + enter(); + type("dt"); + enter(); // 시작점 Enter + expect(promptLine()).toBe("문자 입력:"); + typeEnter("two"); + enter(); + const [one, two] = texts(); + expect(two.size).toBe(4); + close(two.at.x, one.at.x); + close(two.at.y, one.at.y - 4 * 1.35); + type("dt"); + type("50,50"); + expect(promptLine()).toBe("높이 지정 <4.0000>:"); + enter(); + enter(); + enter(); // 첫 줄에서 빈 Enter + idle(); + expect(texts()).toHaveLength(2); + }); + + it("회전 각 — 글 방향 · 다음 줄은 돌린 아래 · Esc = 치던 줄 버림", () => { + type("dt"); + type("10,0"); + type("2"); + type("90"); + typeEnter("up"); + typeEnter("next"); + typed("zz"); + key("Escape"); + idle(); + const [up, next] = texts(); + expect(texts()).toHaveLength(2); + close(up.dir.x, 0); + close(up.dir.y, 1); + close(next.at.x, 10 + 2 * 1.35); + close(next.at.y, 0); + type("dt"); + expect(promptLine()).toBe("문자의 시작점 지정:"); + type("0,0"); + expect(promptLine()).toBe("높이 지정 <2.0000>:"); + enter(); + expect(promptLine()).toBe("문자의 회전 각도 지정 <90>:"); + key("Escape"); + }); +}); + +describe("MTEXT(T · MT)", () => { + it("첫 구석 → H → 반대 구석 → 글(\\P 줄바꿈) · 상자 왼쪽 위에서", () => { + type("t"); + expect(promptLine()).toBe("첫 번째 구석 지정:"); + type("0,100"); + expect(promptLine()).toBe("반대 구석 지정 또는 [높이(H)/폭(W)]:"); + type("c"); + expect(noticeLine()).toBe("「열(C)」 옵션은 이 CAD 에 없음."); + type("h"); + expect(promptLine()).toBe("높이 지정 <2.5000>:"); + type("4"); + expect(promptLine()).toBe("반대 구석 지정 또는 [높이(H)/폭(W)]:"); + type("50,60"); + expect(promptLine()).toBe("문자 입력 (줄바꿈 \\P):"); + typeEnter("a\\Pb"); + idle(); + const [a, b] = texts(); + expect([a.label, b.label]).toEqual(["a", "b"]); + expect(a.size).toBe(4); + close(a.at.x, 0); + close(a.at.y, 96); + close(b.at.y, 96 - 4 * 1.35); + }); + + it("W — 폭 숫자 → 글 · 높이 = 기억(TEXTSIZE) · 빈 글 = 안 만들고 끝", () => { + type("mt"); + type("10,10"); + type("w"); + expect(promptLine()).toBe("폭 지정:"); + type("30"); + expect(promptLine()).toBe("문자 입력 (줄바꿈 \\P):"); + typeEnter("x"); + idle(); + const [x] = texts(); + close(x.at.x, 10); + close(x.at.y, 10 - 2.5); + type("t"); + type("0,0"); + type("5,5"); + enter(); + idle(); + expect(texts()).toHaveLength(1); + }); +}); + +describe("HATCH(H · BH)", () => { + const boxes = () => { + for (const [a, b] of [ + ["0,0", "100,100"], + ["40,40", "60,60"], + ]) { + type("rec"); + type(a); + type(b); + cancelActiveCommand(); + } + expect(entities()).toHaveLength(2); + }; + const spans = () => + entities("Hatch").map((hatch: Any) => { + const xs = (hatch.getPoints() as Xy[]).map((point) => point.x); + return [Math.min(...xs), Math.max(...xs)]; + }); + + beforeEach(boxes); + + it("내부 점 = 품은 것 중 가장 작은 경계 · 되풀이 · 같은 경계 한 번 · Enter = 만듦", () => { + type("h"); + expect(promptLine()).toBe("내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:"); + click(50, 50); + click(10, 10); + click(15, 15); + expect(promptLine()).toBe("내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:"); + enter(); + idle(); + expect(spans()).toEqual([ + [40, 60], + [0, 100], + ]); + }); + + it("U = 마지막 경계 빼기 · 경계 없는 점은 무시 · Esc = 안 만듦", () => { + type("h"); + click(50, 50); + click(10, 10); + click(500, 500); + type("u"); + enter(); + expect(spans()).toEqual([[40, 60]]); + type("h"); + click(10, 10); + key("Escape"); + idle(); + expect(spans()).toHaveLength(1); + type("bh"); + type("t"); + expect(noticeLine()).toBe("「설정(T)」 옵션은 이 CAD 에 없음."); + enter(); // 경계 없이 Enter = 그냥 끝 + idle(); + expect(spans()).toHaveLength(1); + }); + + it("S — 객체 선택 → Enter = 고른 객체 경계로 만듦 · 고름 풂", () => { + type("h"); + type("s"); + expect(promptLine()).toBe("객체 선택:"); + click(0, 50); + enter(); + idle(); + expect(spans()).toEqual([[0, 100]]); + expect(getSelectedEntityIds()).toEqual([]); + }); +}); diff --git a/resources/tester/test_m02_hopyo_fit.py b/resources/tester/test_m02_hopyo_fit.py index 83be778a0..a198d451b 100644 --- a/resources/tester/test_m02_hopyo_fit.py +++ b/resources/tester/test_m02_hopyo_fit.py @@ -3,6 +3,7 @@ 같음을 이김 · 맞음 · 다른 점 · 후보 · 철근 둘 · 고임돌 인력 10% · 맞춤셈 캐시 · 「셈 중」. 실물 = 북부청 임도 · 사방(읽기만) · 층 · 캐시 폴더는 임시.""" +import queue import shutil import time @@ -95,8 +96,12 @@ def layer(tmp_path, monkeypatch, got): return tmp_path, brief["키"], cache -def test_뒤_셈_캐시_셈_중(layer): +def test_뒤_셈_캐시_셈_중(layer, monkeypatch): folder, key, cache = layer + # 앞 시험들의 올리기가 쌓은 일감(북부청 통째)을 기다리지 않게 — 이 시험만의 일감 줄 · 일꾼 + monkeypatch.setattr(fit, "_JOBS", queue.Queue()) + monkeypatch.setattr(fit, "_PENDING", set()) + monkeypatch.setattr(fit, "_WORKER", {"스레드": None}) before = master_hash() assert fit.result(folder, key) is None first = fit.ensure(folder, key)