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/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/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/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/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/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 51c75f739..f7282ffad 100644 --- a/resources/tester/cad/keyboard-chain.test.ts +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -142,13 +142,13 @@ describe("331-1 각도 고정 → 길이", () => { type(controller, "100"); 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("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 = 거절 type(controller, "0,0"); type(controller, "<0"); type(controller, "100"); - expect(getActiveToolActor()?.getSnapshot()?.context.values).toHaveLength(2); + expect(getActiveToolActor()?.getSnapshot()?.context.segments).toHaveLength(1); }); }); @@ -209,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 는 둘째 선부터 — 한 구간이면 무시", () => {