feat(CAD): 337-4a 다-1 고치기 7 — OFFSET · FILLET · CHAMFER · TRIM · EXTEND · BREAK · JOIN 오토캐드 물음 차례 · 옵션 · 기본값 기억 · 빈 Enter · Esc · 끝난 뒤
- OFFSET 새 파일(offset-tool.ts) — 거리 <통과점> · 통과점(T) · 지우기(E) 예/아니오 · 객체 → 면의 점 되풀이 · 다중(M) <다음 객체> · 명령 취소(U) · 기억 OFFSETDIST · OFFSETERASE - FILLET · CHAMFER 한 꼴 — 객체 먼저 · 반지름(R) · 거리(D) 둘(둘째 기본 = 첫째) · 자르기(T) 모드 · 다중(M) · 명령 취소(U) · Shift = 구석 · 기억 FILLETRAD · CHAMFERA/B · TRIMMODE - TRIM · EXTEND 빠른 모드 한 꼴 — 되풀이 · Shift = 반대 일 · 집기 반경 밖 무시 · 지우기(R) · 숨은 U · 사각형 변 원래 층 · 꼴(N9) · 자른 선 꼴 유지 - BREAK — 누른 점 = 첫 끊기점 · 첫 번째 점(F) · @0,0 = 같은 점 · JOIN 물음 글 - 시험 resources/tester/cad/modify-acad.test.ts 13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -1,57 +1,290 @@
|
||||
/** 모깎기·모따기·곡선 혼합·끊기 (조사표 2절) */
|
||||
/**
|
||||
* 모깎기·모따기·곡선 혼합·끊기 (조사표 2절 · 337 설계 1-2)
|
||||
* FILLET · CHAMFER = 오토캐드 차례 — 첫 객체(또는 U · R/D · T · M) → 둘째 객체(Shift = 구석) · 값 · 자르기 모드 기억 ·
|
||||
* 다중이면 쌍 되풀이 · 빈 Enter · Esc = 끝. BREAK = 누른 점이 첫 끊기점 · F = 첫 점 다시 · `@0,0` = 같은 점.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { addEntities, deleteEntities } from '../../state';
|
||||
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
undo,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { closestPointOn, pickEntityAt } from '../eraser-tool.helpers';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import {
|
||||
END_STATE,
|
||||
type MouseClickEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from '../tool.types';
|
||||
import { blendEntities, breakEntity, chamferLines, filletLines } from './corner.helpers';
|
||||
import type { CornerResult } from './corner.helpers';
|
||||
|
||||
function applyCorner(first: Entity, second: Entity, result: CornerResult | null): void {
|
||||
const option = (key: string, kw: string, label: string): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en: key,
|
||||
label,
|
||||
});
|
||||
const entityById = (id: string | null): Entity | undefined =>
|
||||
getEntities().find((entity) => entity.id === id);
|
||||
|
||||
// ---- FILLET · CHAMFER ----
|
||||
|
||||
interface CornerContext extends ToolContext {
|
||||
firstId: string | null;
|
||||
firstPick: Point | null;
|
||||
multiple: boolean;
|
||||
/** 이 명령에서 만든 쌍 수(U) */
|
||||
made: number;
|
||||
/** 값 · 자르기 물음 뒤 돌아갈 물음 */
|
||||
back: 'FIRST' | 'SECOND';
|
||||
/** 방금 누른 쌍을 처리했는가 */
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
interface CornerSpec {
|
||||
tool: Tool;
|
||||
first: string;
|
||||
second: string;
|
||||
/** 반지름(R) · 거리(D) */
|
||||
value: OptionSpec;
|
||||
absentFirst: OptionSpec[];
|
||||
absentSecond: OptionSpec[];
|
||||
/** 값 물음 차례 — shown = 그 열쇠 값을 <기본> 으로 보임 */
|
||||
values: Array<{ text: string; remember: string; shown?: string }>;
|
||||
/** corner = Shift+누름(반지름 · 거리 0) */
|
||||
apply(
|
||||
first: Entity,
|
||||
firstPick: Point,
|
||||
second: Entity,
|
||||
secondPick: Point,
|
||||
corner: boolean
|
||||
): CornerResult | null;
|
||||
}
|
||||
|
||||
const TRIM_OPTION = option('TRIM', 'T', '자르기');
|
||||
const POLYLINE = option('POLYLINE', 'P', '폴리선');
|
||||
const TRIM_MODE_PROMPT = (): PromptSpec => ({
|
||||
text: '자르기 모드 옵션 입력',
|
||||
options: [TRIM_OPTION, option('NOTRIM', 'N', '자르지 않기')],
|
||||
default: { kind: 'option', key: recall<string>('TRIMMODE') ?? 'TRIM' },
|
||||
});
|
||||
|
||||
type CornerCtx = { context: CornerContext; event: StateEvent };
|
||||
|
||||
function applyPair(spec: CornerSpec, context: CornerContext, click: MouseClickEvent): boolean {
|
||||
const first = entityById(context.firstId);
|
||||
const second = pickEntityAt(click.worldMouseLocation);
|
||||
if (!first || !second || !context.firstPick) return false;
|
||||
const result = spec.apply(
|
||||
first,
|
||||
context.firstPick,
|
||||
second,
|
||||
click.worldMouseLocation,
|
||||
!!click.holdingShift
|
||||
);
|
||||
if (!result) {
|
||||
toast.warn('두 직선을 선택해야 합니다. 두 선이 평행하면 처리할 수 없습니다.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (recall('TRIMMODE') === 'NOTRIM') {
|
||||
if (!result.corner) return false;
|
||||
addEntities([result.corner], true);
|
||||
return true;
|
||||
}
|
||||
deleteEntities([first, second], false);
|
||||
addEntities([...result.trimmed, ...(result.corner ? [result.corner] : [])], true);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const filletToolStateMachine = createSequenceTool({
|
||||
function createCornerTool(spec: CornerSpec) {
|
||||
const end = { target: END_STATE };
|
||||
const back = [
|
||||
{ guard: ({ context }: CornerCtx) => context.back === 'SECOND', target: 'SECOND' },
|
||||
{ target: 'FIRST' },
|
||||
];
|
||||
// 값 물음 — 마지막 값 뒤 = 돌아갈 물음
|
||||
const valueStates = Object.fromEntries(
|
||||
spec.values.map((value, index) => {
|
||||
const store = {
|
||||
actions: ({ event }: CornerCtx) =>
|
||||
remember(value.remember, (event as { value: number }).value),
|
||||
};
|
||||
const isLast = index === spec.values.length - 1;
|
||||
return [
|
||||
`VALUE_${index}`,
|
||||
{
|
||||
meta: {
|
||||
kind: 'number',
|
||||
prompt: {
|
||||
text: value.text,
|
||||
default: { kind: 'value', remember: value.shown ?? value.remember, initial: 0 },
|
||||
number: { min: 0 },
|
||||
} satisfies PromptSpec,
|
||||
},
|
||||
on: {
|
||||
NUMBER_INPUT: isLast
|
||||
? back.map((transition) => ({ ...transition, ...store }))
|
||||
: { ...store, target: `VALUE_${index + 1}` },
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
return createMachine({
|
||||
types: {} as { context: CornerContext; events: StateEvent },
|
||||
context: {
|
||||
firstId: null,
|
||||
firstPick: null,
|
||||
multiple: false,
|
||||
made: 0,
|
||||
back: 'FIRST',
|
||||
ok: false,
|
||||
type: spec.tool,
|
||||
},
|
||||
initial: 'INIT',
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음)
|
||||
states: <Record<string, any>>{
|
||||
INIT: {
|
||||
always: {
|
||||
actions: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
target: 'FIRST',
|
||||
},
|
||||
},
|
||||
FIRST: {
|
||||
meta: {
|
||||
kind: 'entity',
|
||||
prompt: {
|
||||
text: spec.first,
|
||||
options: [OPT.UNDO, spec.value, TRIM_OPTION, OPT.MULTIPLE],
|
||||
absent: spec.absentFirst,
|
||||
} satisfies PromptSpec,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
guard: ({ event }: CornerCtx) =>
|
||||
!!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: assign(({ event }: CornerCtx) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
return { firstId: pickEntityAt(location)?.id ?? null, firstPick: location };
|
||||
}),
|
||||
target: 'SECOND',
|
||||
},
|
||||
[`OPTION.${spec.value.key}`]: {
|
||||
actions: assign({ back: 'FIRST' as const }),
|
||||
target: 'VALUE_0',
|
||||
},
|
||||
'OPTION.TRIM': { target: 'TRIM_MODE' },
|
||||
'OPTION.MULTIPLE': { actions: assign({ multiple: true }) },
|
||||
'OPTION.UNDO': {
|
||||
actions: assign(({ context }: CornerCtx) => {
|
||||
if (context.made === 0) return {};
|
||||
undo();
|
||||
return { made: context.made - 1 };
|
||||
}),
|
||||
},
|
||||
ENTER: end,
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
SECOND: {
|
||||
meta: {
|
||||
kind: 'entity',
|
||||
prompt: {
|
||||
text: spec.second,
|
||||
options: [spec.value],
|
||||
absent: spec.absentSecond,
|
||||
} satisfies PromptSpec,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ context, event }: CornerCtx) => {
|
||||
const ok = applyPair(spec, context, event as MouseClickEvent);
|
||||
return { ok, made: context.made + (ok ? 1 : 0) };
|
||||
}),
|
||||
target: 'APPLIED',
|
||||
},
|
||||
[`OPTION.${spec.value.key}`]: {
|
||||
actions: assign({ back: 'SECOND' as const }),
|
||||
target: 'VALUE_0',
|
||||
},
|
||||
ENTER: end,
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
APPLIED: {
|
||||
always: [
|
||||
{ guard: ({ context }: CornerCtx) => !context.ok, target: 'SECOND' },
|
||||
{ guard: ({ context }: CornerCtx) => context.multiple, target: 'FIRST' },
|
||||
{ target: END_STATE },
|
||||
],
|
||||
},
|
||||
TRIM_MODE: {
|
||||
meta: { kind: 'keyword', prompt: TRIM_MODE_PROMPT },
|
||||
on: {
|
||||
'OPTION.TRIM': { actions: () => remember('TRIMMODE', 'TRIM'), target: 'FIRST' },
|
||||
'OPTION.NOTRIM': { actions: () => remember('TRIMMODE', 'NOTRIM'), target: 'FIRST' },
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
...valueStates,
|
||||
[END_STATE]: { type: 'final' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const radius = () => recall<number>('FILLETRAD') ?? 0;
|
||||
|
||||
export const filletToolStateMachine = createCornerTool({
|
||||
tool: Tool.FILLET,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '모깎기 반지름을 입력하십시오 <0>.', defaultValue: 0 },
|
||||
{ kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = input.entity(1);
|
||||
const second = input.entity(2);
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
filletLines(first, input.pick(1), second, input.pick(2), input.number(0))
|
||||
);
|
||||
},
|
||||
first: '첫 번째 객체 선택',
|
||||
second: '두 번째 객체 선택 또는 Shift 키를 누른 채 선택하여 구석 적용',
|
||||
value: option('RADIUS', 'R', '반지름'),
|
||||
absentFirst: [POLYLINE],
|
||||
absentSecond: [],
|
||||
values: [{ text: '모깎기 반지름 지정', remember: 'FILLETRAD' }],
|
||||
apply: (first, firstPick, second, secondPick, corner) =>
|
||||
filletLines(first, firstPick, second, secondPick, corner ? 0 : radius()),
|
||||
});
|
||||
|
||||
export const chamferToolStateMachine = createSequenceTool({
|
||||
const ANGLE = option('ANGLE', 'A', '각도');
|
||||
const METHOD = option('METHOD', 'E', '방법');
|
||||
|
||||
export const chamferToolStateMachine = createCornerTool({
|
||||
tool: Tool.CHAMFER,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '첫 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'number', instructions: '두 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
first: '첫 번째 선 선택',
|
||||
second: '두 번째 선 선택 또는 Shift 키를 누른 채 선택하여 구석 적용',
|
||||
value: option('DISTANCE', 'D', '거리'),
|
||||
absentFirst: [POLYLINE, ANGLE, METHOD],
|
||||
absentSecond: [ANGLE, METHOD],
|
||||
values: [
|
||||
{ text: '첫 번째 모따기 거리 지정', remember: 'CHAMFERA' },
|
||||
{ text: '두 번째 모따기 거리 지정', remember: 'CHAMFERB', shown: 'CHAMFERA' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = input.entity(2);
|
||||
const second = input.entity(3);
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
chamferLines(first, input.pick(2), second, input.pick(3), input.number(0), input.number(1))
|
||||
);
|
||||
apply: (first, firstPick, second, secondPick, corner) => {
|
||||
const a = recall<number>('CHAMFERA') ?? 0;
|
||||
const b = recall<number>('CHAMFERB') ?? 0;
|
||||
// 거리 0 · Shift = 구석(모깎기 반지름 0 과 같음)
|
||||
return corner || (a === 0 && b === 0)
|
||||
? filletLines(first, firstPick, second, secondPick, 0)
|
||||
: chamferLines(first, firstPick, second, secondPick, a, b);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,18 +305,111 @@ export const blendToolStateMachine = createSequenceTool({
|
||||
},
|
||||
});
|
||||
|
||||
export const breakToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BREAK,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '끊을 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 끊기 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 끊기 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const pieces = breakEntity(entity, input.point(1), input.point(2));
|
||||
deleteEntities([entity], false);
|
||||
addEntities(pieces, true);
|
||||
// ---- BREAK ----
|
||||
|
||||
interface BreakContext extends ToolContext {
|
||||
entityId: string | null;
|
||||
first: Point | null;
|
||||
}
|
||||
|
||||
type BreakCtx = { context: BreakContext; event: StateEvent };
|
||||
|
||||
/** 두 끊기점 사이를 지움 · 같은 점 = 틈 없이 둘로 */
|
||||
function breakNow(context: BreakContext, event: StateEvent) {
|
||||
const entity = entityById(context.entityId);
|
||||
if (!entity || !context.first) return;
|
||||
const first = closestPointOn(entity, context.first);
|
||||
const second = closestPointOn(entity, getPointFromEvent(context.first, event as PointInputEvent));
|
||||
const same = Math.hypot(first.x - second.x, first.y - second.y) < 1e-9;
|
||||
const pieces = same ? breakEntity(entity, first) : breakEntity(entity, first, second);
|
||||
if (same && pieces.length < 2) return;
|
||||
deleteEntities([entity], false);
|
||||
addEntities(pieces, true);
|
||||
}
|
||||
|
||||
const POINT_EVENTS = [
|
||||
'MOUSE_CLICK',
|
||||
'ABSOLUTE_POINT_INPUT',
|
||||
'RELATIVE_POINT_INPUT',
|
||||
'NUMBER_INPUT',
|
||||
];
|
||||
const onPoint = (transition: object) =>
|
||||
Object.fromEntries(POINT_EVENTS.map((type) => [type, transition]));
|
||||
const FIRST_POINT = option('FIRST', 'F', '첫 번째 점');
|
||||
|
||||
export const breakToolStateMachine = createMachine({
|
||||
types: {} as { context: BreakContext; events: StateEvent },
|
||||
context: { entityId: null, first: null, type: Tool.BREAK },
|
||||
initial: 'INIT',
|
||||
states: {
|
||||
INIT: {
|
||||
always: {
|
||||
actions: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
target: 'OBJECT',
|
||||
},
|
||||
},
|
||||
OBJECT: {
|
||||
meta: { kind: 'entity', prompt: { text: '객체 선택' } satisfies PromptSpec },
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
guard: ({ event }: BreakCtx) =>
|
||||
!!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: assign(({ event }: BreakCtx) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
const entity = pickEntityAt(location) as Entity;
|
||||
const first = closestPointOn(entity, location);
|
||||
setAngleGuideOriginPoint(first);
|
||||
return { entityId: entity.id, first };
|
||||
}),
|
||||
target: 'SECOND',
|
||||
},
|
||||
ENTER: { target: END_STATE },
|
||||
ESC: { target: END_STATE },
|
||||
},
|
||||
},
|
||||
SECOND: {
|
||||
meta: {
|
||||
kind: 'point',
|
||||
prompt: { text: '두 번째 끊기점 지정', options: [FIRST_POINT] } satisfies PromptSpec,
|
||||
},
|
||||
entry: () => setShouldDrawHelpers(true),
|
||||
on: {
|
||||
...onPoint({
|
||||
actions: ({ context, event }: BreakCtx) => breakNow(context, event),
|
||||
target: END_STATE,
|
||||
}),
|
||||
'OPTION.FIRST': { target: 'FIRST_POINT' },
|
||||
ESC: { target: END_STATE },
|
||||
},
|
||||
},
|
||||
FIRST_POINT: {
|
||||
meta: { kind: 'point', prompt: { text: '첫 번째 끊기점 지정' } satisfies PromptSpec },
|
||||
on: {
|
||||
...onPoint({
|
||||
actions: assign(({ context, event }: BreakCtx) => {
|
||||
const first = getPointFromEvent(context.first, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(first);
|
||||
return { first };
|
||||
}),
|
||||
target: 'SECOND_POINT',
|
||||
}),
|
||||
ESC: { target: END_STATE },
|
||||
},
|
||||
},
|
||||
SECOND_POINT: {
|
||||
meta: { kind: 'point', prompt: { text: '두 번째 끊기점 지정' } satisfies PromptSpec },
|
||||
on: {
|
||||
...onPoint({
|
||||
actions: ({ context, event }: BreakCtx) => breakNow(context, event),
|
||||
target: END_STATE,
|
||||
}),
|
||||
ESC: { target: END_STATE },
|
||||
},
|
||||
},
|
||||
[END_STATE]: { type: 'final' },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* OFFSET(337 설계 1-2) — 오토캐드 간격띄우기 차례: 거리(또는 통과점(T) · 지우기(E)) → 객체 → 면의 점 →
|
||||
* 객체로 되풀이 · 다중(M) = 방금 만든 것을 같은 거리로 되풀이 · U = 이 명령 안 마지막 하나 · 빈 Enter · Esc = 끝.
|
||||
* 거리 기억 = OFFSETDIST(처음 <통과점>) · 원본 지우기 기억 = OFFSETERASE.
|
||||
*/
|
||||
import { type Point, Segment } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { findClosestEntity } from '../../helpers/find-closest-entity';
|
||||
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
undo,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { pickEntityAt } from '../eraser-tool.helpers';
|
||||
import {
|
||||
type DrawEvent,
|
||||
END_STATE,
|
||||
type MouseClickEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from '../tool.types';
|
||||
import { offsetEntity } from './modify.helpers';
|
||||
|
||||
interface OffsetContext extends ToolContext {
|
||||
/** 간격띄울 객체 */
|
||||
sourceId: string | null;
|
||||
/** 처음 고른 객체 — 원본 지우기는 이것만 */
|
||||
pickedId: string | null;
|
||||
multiple: boolean;
|
||||
/** 이 명령에서 만든 수(U) */
|
||||
made: number;
|
||||
}
|
||||
|
||||
type Ctx = { context: OffsetContext; event: StateEvent };
|
||||
|
||||
const option = (key: string, kw: string, label: string): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en: key,
|
||||
label,
|
||||
});
|
||||
const THROUGH = option('THROUGH', 'T', '통과점');
|
||||
|
||||
/** 기억한 거리 · null = 통과점 */
|
||||
const offsetDistance = (): number | null => {
|
||||
const distance = recall('OFFSETDIST');
|
||||
return typeof distance === 'number' ? distance : null;
|
||||
};
|
||||
|
||||
const DISTANCE_PROMPT = (): PromptSpec => {
|
||||
const distance = offsetDistance();
|
||||
return {
|
||||
text: '간격띄우기 거리 지정',
|
||||
options: [THROUGH, option('ERASE', 'E', '지우기')],
|
||||
absent: [option('LAYER', 'L', '도면층')],
|
||||
default:
|
||||
distance === null
|
||||
? { kind: 'option', key: 'THROUGH' }
|
||||
: { kind: 'value', remember: 'OFFSETDIST', initial: distance },
|
||||
number: { positive: true },
|
||||
};
|
||||
};
|
||||
const ERASE_PROMPT = (): PromptSpec => ({
|
||||
text: '간격띄우기 후 원본 객체를 지우시겠습니까?',
|
||||
options: [OPT.YES, OPT.NO],
|
||||
default: { kind: 'option', key: recall('OFFSETERASE') ? 'YES' : 'NO' },
|
||||
});
|
||||
const OBJECT_PROMPT: PromptSpec = {
|
||||
text: '간격띄우기할 객체 선택',
|
||||
options: [OPT.EXIT, OPT.UNDO],
|
||||
default: { kind: 'enter', label: '종료' },
|
||||
};
|
||||
const SIDE_PROMPT = (context: Partial<OffsetContext>): PromptSpec => ({
|
||||
text: offsetDistance() === null ? '통과점 지정' : '간격띄우기할 면의 점 지정',
|
||||
options: context.multiple ? [OPT.EXIT, OPT.UNDO] : [OPT.EXIT, OPT.MULTIPLE, OPT.UNDO],
|
||||
default: { kind: 'enter', label: context.multiple ? '다음 객체' : '종료' },
|
||||
});
|
||||
|
||||
/** 통과점까지 거리 — 선은 무한 직선까지(오토캐드) · 그 밖은 모양까지 */
|
||||
function throughDistance(entity: Entity, point: Point): number {
|
||||
const shape = entity.getShape();
|
||||
if (shape instanceof Segment) {
|
||||
const { start, end } = shape;
|
||||
const length = start.distanceTo(end)[0] || 1;
|
||||
return (
|
||||
Math.abs((end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)) /
|
||||
length
|
||||
);
|
||||
}
|
||||
return findClosestEntity(point, [entity]).distance;
|
||||
}
|
||||
|
||||
function offsetThrough(source: Entity, point: Point): Entity | null {
|
||||
return offsetEntity(source, offsetDistance() ?? throughDistance(source, point), point);
|
||||
}
|
||||
|
||||
const sourceOf = (context: OffsetContext) =>
|
||||
getEntities().find((entity) => entity.id === context.sourceId);
|
||||
|
||||
/** 면의 점 하나 → 새 객체 · 다중이면 그것이 다음 원본 */
|
||||
const placeOffset = assign(({ context, event }: Ctx): Partial<OffsetContext> => {
|
||||
const source = sourceOf(context);
|
||||
if (!source) return { sourceId: null };
|
||||
const point = getPointFromEvent(null, event as PointInputEvent);
|
||||
const result = offsetThrough(source, point);
|
||||
setGhostHelperEntities([]);
|
||||
if (!result) {
|
||||
toast.warn('이 객체는 지정한 거리로 간격을 띄울 수 없습니다.');
|
||||
return {};
|
||||
}
|
||||
if (recall('OFFSETERASE') && source.id === context.pickedId) deleteEntities([source], false);
|
||||
addEntities([result], true);
|
||||
return { made: context.made + 1, sourceId: context.multiple ? result.id : null };
|
||||
});
|
||||
|
||||
const undoLast = assign(({ context }: Ctx): Partial<OffsetContext> => {
|
||||
setGhostHelperEntities([]);
|
||||
if (context.made === 0) return {};
|
||||
undo();
|
||||
return { made: context.made - 1, sourceId: null, multiple: false };
|
||||
});
|
||||
|
||||
const end = { target: END_STATE };
|
||||
const toObject = { actions: assign({ sourceId: null, multiple: false }), target: 'OBJECT' };
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음)
|
||||
const states: Record<string, any> = {
|
||||
INIT: {
|
||||
always: {
|
||||
actions: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
target: 'DISTANCE',
|
||||
},
|
||||
},
|
||||
DISTANCE: {
|
||||
meta: { kind: 'number', prompt: DISTANCE_PROMPT },
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
actions: ({ event }: Ctx) => remember('OFFSETDIST', (event as { value: number }).value),
|
||||
target: 'OBJECT',
|
||||
},
|
||||
'OPTION.THROUGH': { actions: () => remember('OFFSETDIST', 'THROUGH'), target: 'OBJECT' },
|
||||
'OPTION.ERASE': { target: 'ERASE' },
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
ERASE: {
|
||||
meta: { kind: 'keyword', prompt: ERASE_PROMPT },
|
||||
on: {
|
||||
'OPTION.YES': { actions: () => remember('OFFSETERASE', true), target: 'DISTANCE' },
|
||||
'OPTION.NO': { actions: () => remember('OFFSETERASE', false), target: 'DISTANCE' },
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
OBJECT: {
|
||||
meta: { kind: 'entity', prompt: OBJECT_PROMPT },
|
||||
entry: () => setShouldDrawHelpers(false),
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
guard: ({ event }: Ctx) => !!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: assign(({ event }: Ctx) => {
|
||||
const id = pickEntityAt((event as MouseClickEvent).worldMouseLocation)?.id ?? null;
|
||||
return { sourceId: id, pickedId: id };
|
||||
}),
|
||||
target: 'SIDE',
|
||||
},
|
||||
'OPTION.EXIT': end,
|
||||
'OPTION.UNDO': { actions: undoLast },
|
||||
ENTER: end,
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
SIDE: {
|
||||
meta: { kind: 'point', prompt: SIDE_PROMPT },
|
||||
entry: () => setShouldDrawHelpers(true),
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const source = sourceOf(context);
|
||||
const preview =
|
||||
source && offsetThrough(source, getPointFromEvent(null, event as DrawEvent));
|
||||
setGhostHelperEntities(preview ? [preview] : []);
|
||||
},
|
||||
},
|
||||
MOUSE_CLICK: { actions: placeOffset, target: 'PLACED' },
|
||||
ABSOLUTE_POINT_INPUT: { actions: placeOffset, target: 'PLACED' },
|
||||
'OPTION.EXIT': end,
|
||||
'OPTION.MULTIPLE': { actions: assign({ multiple: true }) },
|
||||
'OPTION.UNDO': { actions: undoLast, target: 'OBJECT' },
|
||||
ENTER: [{ guard: ({ context }: Ctx) => context.multiple, ...toObject }, end],
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
/** 다중이면 같은 물음 · 아니면 객체로 */
|
||||
PLACED: {
|
||||
always: [{ guard: ({ context }: Ctx) => !!context.sourceId, target: 'SIDE' }, toObject],
|
||||
},
|
||||
[END_STATE]: { type: 'final' },
|
||||
};
|
||||
|
||||
export const offsetToolStateMachine = createMachine({
|
||||
types: {} as { context: OffsetContext; events: StateEvent },
|
||||
context: { sourceId: null, pickedId: null, multiple: false, made: 0, type: Tool.OFFSET },
|
||||
initial: 'INIT',
|
||||
states,
|
||||
});
|
||||
@@ -1,35 +1,27 @@
|
||||
/** 연장·결합·분해·지우기 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import { SELECT_OBJECTS } from '../../commands/prompt';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { addEntities, deleteEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { explodeEntity, extendLineToBoundary, joinEntities } from './modify.helpers';
|
||||
import { explodeEntity, joinEntities } from './modify.helpers';
|
||||
|
||||
export const extendToolStateMachine = createSequenceTool({
|
||||
tool: Tool.EXTEND,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '경계로 쓸 객체를 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '연장할 선을 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const boundary = input.entity(0);
|
||||
const target = input.entity(1);
|
||||
const extended = extendLineToBoundary(target, boundary);
|
||||
if (!extended) {
|
||||
toast.warn('경계와 만나도록 연장할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([target], false);
|
||||
addEntities([extended], true);
|
||||
},
|
||||
});
|
||||
/** EXTEND 빠른 모드는 TRIM 과 한 머신 꼴(eraser-tool.ts) */
|
||||
export { extendToolStateMachine } from '../eraser-tool';
|
||||
|
||||
export const joinToolStateMachine = createSequenceTool({
|
||||
tool: Tool.JOIN,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '결합할 객체를 선택한 뒤 ENTER.' }],
|
||||
steps: [
|
||||
{
|
||||
kind: 'selection',
|
||||
prompt: {
|
||||
text: '한 번에 결합할 원본 객체 또는 여러 객체 선택',
|
||||
options: SELECT_OBJECTS.options,
|
||||
},
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
const joined = joinEntities(entities);
|
||||
|
||||
Reference in New Issue
Block a user