/** * 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 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 { 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 { 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, 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, 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, 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 = Math.abs(offset.y - (start.y + end.y) / 2) >= Math.abs(offset.x - (start.x + end.x) / 2); if (offsetIsVertical || horizontalSpan >= verticalSpan) { return [start, new Point(end.x, start.y)]; } return [start, new Point(start.x, end.y)]; } /** 객체 선택 — 선 · 폴리선(누른 토막) · 그 밖은 처음 · 끝 점 */ 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 ); } const points = sampleEntityPoints(target); if (points.length < 2) return null; return [points[0], points[points.length - 1]]; } 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', prompt: { ...SELECT_OBJECTS, text: '치수 기입할 형상 선택' } }, { kind: 'point', prompt: { text: '치수선의 위치 지정' } }, ], commit: (input) => { const offset = input.point(1); const dimensions = input .entities(0) .map((entity) => { const points = sampleEntityPoints(entity); if (points.length < 2) return null; return makeDimension(points[0], points[points.length - 1], offset); }) .filter((dimension): dimension is MeasurementEntity => !!dimension); if (!dimensions.length) { toast.warn('치수를 넣을 수 있는 객체가 없습니다.'); return; } addEntities(dimensions, true); setSelectedEntityIds([]); }, }); /** DIM — 선택한 객체 종류에 맞는 치수를 자동으로 고른다 */ export const dimAutoToolStateMachine = createSequenceTool({ tool: Tool.DIM, helpers: false, steps: [ { kind: 'entity', prompt: { text: '치수 기입할 객체 선택' } }, { kind: 'point', prompt: { text: '치수선의 위치 지정' } }, ], commit: (input) => { const entity = input.entity(0); const offset = input.point(1); const shape = entity.getShape(); if (shape instanceof Circle || entity.getType() === EntityName.Arc) { // 원·호는 반지름 치수 명령이 더 알맞다 toast.info('원·호에는 DIMRADIUS 또는 DIMDIAMETER를 사용하십시오.'); return; } const points = sampleEntityPoints(entity); if (points.length < 2) { toast.warn('치수를 넣을 수 없는 객체입니다.'); return; } addEntities([makeDimension(points[0], points[points.length - 1], offset)], true); }, });