/** * ROTATE · SCALE 공용 틀(337 라 · 설계 1-3) — 「객체 선택:」 → 「기준점 지정:」 → * 「<값> 지정 또는 [복사(C)/참조(R)] <기본>:」(누름 = 기준점에서 그 점까지 잰 값 · 숫자 = 값 · 빈 Enter = 기억 값) · * C = 사본 켬/끔 · R → 「참조 … <기본>:」(숫자 · 두 점) → 「새 … 지정 또는 [점(P)] <기본>:」(숫자 · 누름 · P 두 점). * 한 번 뒤 끝 · Esc = 제자리 끝 · 원본은 확정까지 도면에 그대로. */ import type { Point } from '@flatten-js/core'; import { assign, createMachine } from 'xstate'; import { OPT, type OptionSpec, type PromptSpec, remember } from '../commands/prompt'; import type { Entity } from '../entities/Entity'; import { getPointFromEvent } from '../helpers/get-point-from-event'; import { setAngleGuideOriginPoint, setGhostHelperEntities, setShouldDrawHelpers } from '../state'; import type { Tool } from '../tools'; import { type DrawEvent, END_STATE, type NumberInputEvent, type PointInputEvent, type StateEvent, type ToolContext, } from './tool.types'; import { type PickedContext, addCopies, changedCopies, endState, guideLine, replaceInPlace, selectionState, showGhosts, startState, } from './transform.helpers'; export enum PivotState { INIT = 'INIT', WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', WAITING_FOR_BASE_POINT = 'WAITING_FOR_BASE_POINT', WAITING_FOR_VALUE = 'WAITING_FOR_VALUE', WAITING_FOR_REFERENCE = 'WAITING_FOR_REFERENCE', WAITING_FOR_REFERENCE_SECOND_POINT = 'WAITING_FOR_REFERENCE_SECOND_POINT', WAITING_FOR_NEW_VALUE = 'WAITING_FOR_NEW_VALUE', WAITING_FOR_NEW_FIRST_POINT = 'WAITING_FOR_NEW_FIRST_POINT', WAITING_FOR_NEW_SECOND_POINT = 'WAITING_FOR_NEW_SECOND_POINT', } export interface PivotContext extends ToolContext, PickedContext { base: Point | null; /** 복사(C) — 원본 두고 사본을 바꿈 */ copy: boolean; /** 참조 값(R) */ reference: number | null; /** 참조 · 새 값 두 점의 첫 점 */ first: Point | null; } interface ValuePrompt { text: string; remember: string; initial: number; } export interface PivotConfig { tool: Tool; format: 'ang' | 'len'; value: ValuePrompt; reference: ValuePrompt; next: ValuePrompt; /** 값 · 참조 · 새 값 숫자 검사 */ number?: PromptSpec['number']; reject?: string; /** C 켬 · 끔 알림 */ copyNotice: [string, string]; /** 못 쓰는 값 알림 */ invalidNotice: string; /** 두 점으로 잰 값(각 · 거리) */ measure: (a: Point, b: Point) => number; /** 새 값 · 참조 → 값(못 쓰면 null) */ combine: (next: number, reference: number) => number | null; valid: (value: number) => boolean; apply: (entity: Entity, base: Point, value: number) => void; } const POINTS: OptionSpec = { key: 'POINTS', kw: 'P', en: 'POINTS', label: '점' }; type Ctx = { context: PivotContext; event: StateEvent }; const noticeLater = (text: string) => { void import('../inputController/prompt-input').then(({ notice }) => notice(text)); }; export function createPivotTool(config: PivotConfig) { const S = PivotState; const valuePrompt = (spec: ValuePrompt, options?: OptionSpec[]): PromptSpec => ({ text: spec.text, ...(options ? { options } : {}), default: { kind: 'value', remember: spec.remember, initial: spec.initial, format: config.format, }, ...(config.number ? { number: config.number } : {}), ...(config.reject ? { reject: config.reject } : {}), }); const PROMPT = { base: { text: '기준점 지정' }, value: valuePrompt(config.value, [OPT.COPY, OPT.REFERENCE]), reference: valuePrompt(config.reference), second: { text: '두 번째 점 지정' }, next: valuePrompt(config.next, [POINTS]), first: { text: '첫 번째 점 지정' }, } satisfies Record; const pointOf = (from: Point | null, event: StateEvent) => getPointFromEvent(from, event as PointInputEvent); const ok = (value: number | null): value is number => value !== null && Number.isFinite(value) && config.valid(value); const transform = (context: PivotContext, value: number) => (entity: Entity) => config.apply(entity, context.base as Point, value); const commit = (context: PivotContext, value: number) => { if (context.copy) addCopies(context.originals, transform(context, value)); else replaceInPlace(changedCopies(context.originals, transform(context, value))); remember(config.value.remember, value); }; const preview = (context: PivotContext, value: number | null, from: Point, cursor: Point) => { if (ok(value)) showGhosts(context.originals, transform(context, value), guideLine(from, cursor)); else setGhostHelperEntities([guideLine(from, cursor)]); }; /** 값을 셈해 맞으면 확정 · 끝 · 아니면 알림 뒤 그 물음 */ const settle = (compute: (args: Ctx) => number | null) => [ { guard: (args: Ctx) => ok(compute(args)), actions: (args: Ctx) => commit(args.context, compute(args) as number), target: END_STATE, }, { actions: () => noticeLater(config.invalidNotice) }, ]; const onPoints = (handler: unknown) => ({ MOUSE_CLICK: handler, ABSOLUTE_POINT_INPUT: handler, RELATIVE_POINT_INPUT: handler, NUMBER_INPUT: handler, }); const numberOr = (args: Ctx, point: (p: Point) => number) => args.event.type === 'NUMBER_INPUT' ? (args.event as NumberInputEvent).value : point(pointOf(args.context.base, args.event)); const nextValue = (args: Ctx, raw: number) => { remember(config.next.remember, raw); return config.combine(raw, args.context.reference ?? 0); }; const cursorOf = (event: StateEvent, from: Point) => getPointFromEvent(from, event as DrawEvent); const esc = { ESC: { target: END_STATE } }; // biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다 const states: Record = { [S.INIT]: startState(S.WAITING_FOR_BASE_POINT), [S.WAITING_FOR_SELECTION]: selectionState(S.WAITING_FOR_BASE_POINT), [S.WAITING_FOR_BASE_POINT]: { meta: { kind: 'point', prompt: PROMPT.base }, entry: () => setShouldDrawHelpers(true), on: { MOUSE_CLICK: { actions: assign(({ event }: Ctx) => { const base = pointOf(null, event); setAngleGuideOriginPoint(base); return { base }; }), target: S.WAITING_FOR_VALUE, }, ABSOLUTE_POINT_INPUT: { actions: assign(({ event }: Ctx) => { const base = pointOf(null, event); setAngleGuideOriginPoint(base); return { base }; }), target: S.WAITING_FOR_VALUE, }, ...esc, }, }, [S.WAITING_FOR_VALUE]: { meta: { kind: 'point', prompt: PROMPT.value }, on: { DRAW: { actions: ({ context, event }: Ctx) => { const base = context.base as Point; const cursor = cursorOf(event, base); preview(context, config.measure(base, cursor), base, cursor); }, }, ...onPoints( settle((args) => numberOr(args, (p) => config.measure(args.context.base as Point, p))) ), 'OPTION.COPY': { actions: [ assign(({ context }: Ctx) => ({ copy: !context.copy })), ({ context }: Ctx) => noticeLater(config.copyNotice[context.copy ? 0 : 1]), ], }, 'OPTION.REFERENCE': { target: S.WAITING_FOR_REFERENCE }, ...esc, }, }, [S.WAITING_FOR_REFERENCE]: { meta: { kind: 'point', prompt: PROMPT.reference }, entry: () => setGhostHelperEntities([]), on: { NUMBER_INPUT: { actions: assign(({ event }: Ctx) => { const reference = (event as NumberInputEvent).value; remember(config.reference.remember, reference); return { reference }; }), target: S.WAITING_FOR_NEW_VALUE, }, ...Object.fromEntries( ['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT', 'RELATIVE_POINT_INPUT'].map((type) => [ type, { actions: assign(({ context, event }: Ctx) => { const first = pointOf(context.base, event); setAngleGuideOriginPoint(first); return { first }; }), target: S.WAITING_FOR_REFERENCE_SECOND_POINT, }, ]) ), ...esc, }, }, [S.WAITING_FOR_REFERENCE_SECOND_POINT]: { meta: { kind: 'point', prompt: PROMPT.second }, on: { DRAW: { actions: ({ context, event }: Ctx) => { const first = context.first as Point; setGhostHelperEntities([guideLine(first, cursorOf(event, first))]); }, }, ...onPoints({ actions: assign(({ context, event }: Ctx) => { const first = context.first as Point; const reference = config.measure(first, pointOf(first, event)); remember(config.reference.remember, reference); setAngleGuideOriginPoint(context.base); return { reference, first: null }; }), target: S.WAITING_FOR_NEW_VALUE, }), ...esc, }, }, [S.WAITING_FOR_NEW_VALUE]: { meta: { kind: 'point', prompt: PROMPT.next }, on: { DRAW: { actions: ({ context, event }: Ctx) => { const base = context.base as Point; const cursor = cursorOf(event, base); preview( context, config.combine(config.measure(base, cursor), context.reference ?? 0), base, cursor ); }, }, ...onPoints( settle((args) => nextValue( args, numberOr(args, (p) => config.measure(args.context.base as Point, p)) ) ) ), 'OPTION.POINTS': { target: S.WAITING_FOR_NEW_FIRST_POINT }, ...esc, }, }, [S.WAITING_FOR_NEW_FIRST_POINT]: { meta: { kind: 'point', prompt: PROMPT.first }, entry: () => setGhostHelperEntities([]), on: { ...Object.fromEntries( ['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT', 'RELATIVE_POINT_INPUT'].map((type) => [ type, { actions: assign(({ context, event }: Ctx) => { const first = pointOf(context.base, event); setAngleGuideOriginPoint(first); return { first }; }), target: S.WAITING_FOR_NEW_SECOND_POINT, }, ]) ), ...esc, }, }, [S.WAITING_FOR_NEW_SECOND_POINT]: { meta: { kind: 'point', prompt: PROMPT.second }, on: { DRAW: { actions: ({ context, event }: Ctx) => { const first = context.first as Point; const cursor = cursorOf(event, first); preview( context, config.combine(config.measure(first, cursor), context.reference ?? 0), first, cursor ); }, }, ...onPoints( settle((args) => { const first = args.context.first as Point; return nextValue(args, config.measure(first, pointOf(first, args.event))); }) ), ...esc, }, }, [END_STATE]: endState, }; return createMachine({ types: {} as { context: PivotContext; events: StateEvent }, context: { originals: [], base: null, copy: false, reference: null, first: null, type: config.tool, }, initial: S.INIT, states, }); }