Files
Aislo/B07_DesignDetail/openwebcad/src/tools/modify/offset-tool.ts
T
eomsangdonandClaude Opus 5.5 00a379595b 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
2026-10-16 00:14:29 +09:00

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,
});