- 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
220 lines
7.4 KiB
TypeScript
220 lines
7.4 KiB
TypeScript
/**
|
|
* 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,
|
|
});
|