Files
Aislo/B07_DesignDetail/openwebcad/src/tools/pivot-tool.ts
T
eomsangdonandClaude Opus 5.5 d35bdfa69d feat(CAD): 337-5 라 옮기기 무리 1 단계 — MOVE · ROTATE · SCALE 오토캐드 물음 차례
- MOVE: 기준점 [변위(D)] <변위> · 두 번째 점 <첫 번째 점을 변위로 사용> · 변위 기억(MOVE.DISP) · 한 번 뒤 끝 · Esc 제자리 끝
- ROTATE · SCALE: 공용 틀(pivot-tool) — 누름 한 번 = 값 · 복사(C) · 참조(R) → 새 값 [점(P)] · 각 · 비율 기억 · 0 이하 축척 거절
- 공용 고르기 상태(transform.helpers) — PICKFIRST · 빈 고르기 Enter = 끝 · OPTION.* 넘김 · 원본은 확정까지 도면에 그대로(N1 · N2)
- 시험 transform-acad(15) · scale-tool 단위 시험 새 상태 이름

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

346 lines
11 KiB
TypeScript

/**
* 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<string, PromptSpec>;
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<string, any> = {
[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,
});
}