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
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user