- 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
346 lines
11 KiB
TypeScript
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,
|
|
});
|
|
}
|