- 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
271 lines
8.5 KiB
TypeScript
271 lines
8.5 KiB
TypeScript
/**
|
|
* TRIM · EXTEND 빠른 모드(337 설계 1-2) — 누를 때마다 자름(연장) · Shift+누름 = 반대 일 · 되풀이 ·
|
|
* 빈 Enter · Esc = 끝 · 집기 반경 밖 누름은 아무 일 없음(N9) · U = 이 명령 안 마지막 하나 ·
|
|
* TRIM 지우기(R) = 고르기 → Enter 로 지우고 돌아옴.
|
|
*/
|
|
import type { Point, Polygon } from '@flatten-js/core';
|
|
import { assign, createMachine, sendTo } from 'xstate';
|
|
import {
|
|
OPT,
|
|
type OptionSpec,
|
|
type PromptSpec,
|
|
SELECT_OBJECTS,
|
|
rememberSelection,
|
|
} from '../commands/prompt';
|
|
import type { ArcEntity } from '../entities/ArcEntity';
|
|
import type { CircleEntity } from '../entities/CircleEntity';
|
|
import { EntityName } from '../entities/Entity';
|
|
import { LineEntity } from '../entities/LineEntity';
|
|
import type { RectangleEntity } from '../entities/RectangleEntity';
|
|
import { findClosestEntity } from '../helpers/find-closest-entity';
|
|
import { pickRadius } from '../helpers/pick-radius';
|
|
import { polygonToSegments } from '../helpers/polygon-to-segments';
|
|
import {
|
|
addEntities,
|
|
deleteEntities,
|
|
getEntities,
|
|
getPickableEntities,
|
|
getSelectedEntities,
|
|
setEntities,
|
|
setGhostHelperEntities,
|
|
setSelectedEntityIds,
|
|
setShouldDrawHelpers,
|
|
undo,
|
|
} from '../state';
|
|
import { Tool } from '../tools';
|
|
import {
|
|
eraseArcSegment,
|
|
eraseCircleSegment,
|
|
eraseLineSegment,
|
|
extendLineEnd,
|
|
getAllIntersectionPoints,
|
|
pickEntityAt,
|
|
} from './eraser-tool.helpers';
|
|
import { copyStyle } from './modify/modify.helpers';
|
|
import { selectToolStateMachine } from './select-tool';
|
|
import { END_STATE, type MouseClickEvent, type StateEvent, type ToolContext } from './tool.types';
|
|
|
|
export interface EraserContext extends ToolContext {
|
|
/** 이 명령에서 바꾼 횟수(U) */
|
|
made: number;
|
|
}
|
|
|
|
export enum EraserState {
|
|
INIT = 'INIT',
|
|
WAITING_FOR_FIRST_CLICK = 'WAITING_FOR_FIRST_CLICK',
|
|
ERASE_PICK = 'ERASE_PICK',
|
|
}
|
|
|
|
const absent = (key: string, kw: string, label: string): OptionSpec => ({
|
|
key,
|
|
kw,
|
|
en: key,
|
|
label,
|
|
});
|
|
const HIDDEN_UNDO: OptionSpec = { ...OPT.UNDO, hidden: true };
|
|
const CROSSING = absent('CROSSING', 'C', '걸치기');
|
|
const MODE = absent('MODE', 'O', '모드');
|
|
const PROJECT = absent('PROJECT', 'P', '투영');
|
|
|
|
const TRIM_PROMPT: PromptSpec = {
|
|
text: '자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장',
|
|
options: [{ key: 'ERASE', kw: 'R', en: 'ERASE', label: '지우기' }, HIDDEN_UNDO],
|
|
absent: [absent('CUTTINGEDGES', 'T', '절단 모서리'), CROSSING, MODE, PROJECT],
|
|
};
|
|
const EXTEND_PROMPT: PromptSpec = {
|
|
text: '연장할 객체 선택 또는 Shift 키를 누른 채 선택하여 자르기',
|
|
options: [HIDDEN_UNDO],
|
|
absent: [absent('BOUNDARYEDGES', 'B', '경계 모서리'), CROSSING, MODE, PROJECT],
|
|
};
|
|
|
|
/** TRIM 한 번 — 누른 자리 반경 안 객체만(없으면 false) · 사각형 변은 원래 층 · 꼴 */
|
|
export function handleMouseClick(
|
|
worldMouseLocation: Point,
|
|
radius = Number.POSITIVE_INFINITY
|
|
): boolean {
|
|
const closestEntity = findClosestEntity(worldMouseLocation, getPickableEntities());
|
|
if (!closestEntity.entity || closestEntity.distance > radius) {
|
|
return false;
|
|
}
|
|
|
|
const clickedPointOnShape = closestEntity.segment.start;
|
|
|
|
// Find entities that intersect with the closest entity
|
|
const intersections = getAllIntersectionPoints(closestEntity.entity, getPickableEntities());
|
|
|
|
const entityType = closestEntity.entity.getType();
|
|
switch (entityType) {
|
|
case EntityName.Line: {
|
|
const line = closestEntity.entity as LineEntity;
|
|
eraseLineSegment(line, clickedPointOnShape, intersections);
|
|
break;
|
|
}
|
|
|
|
case EntityName.Circle: {
|
|
const circle = closestEntity.entity as CircleEntity;
|
|
eraseCircleSegment(circle, clickedPointOnShape, intersections);
|
|
break;
|
|
}
|
|
|
|
case EntityName.Arc: {
|
|
const arc = closestEntity.entity as ArcEntity;
|
|
eraseArcSegment(arc, clickedPointOnShape, intersections);
|
|
break;
|
|
}
|
|
|
|
case EntityName.Rectangle: {
|
|
const rectangle = closestEntity.entity as RectangleEntity;
|
|
const segments = polygonToSegments(rectangle.getShape() as Polygon);
|
|
const segmentEntities = segments.map((segment) =>
|
|
copyStyle(rectangle, new LineEntity(rectangle.layerId, segment))
|
|
);
|
|
|
|
// Find the closest segment to the clicked point
|
|
const closestSegmentInfo = findClosestEntity(worldMouseLocation, segmentEntities);
|
|
|
|
// Remove the rectangle
|
|
deleteEntities([rectangle], false);
|
|
|
|
// Add 4 lines instead of the rectangle
|
|
addEntities(segmentEntities, false);
|
|
|
|
// Remove (a segment) of the 4th line closest to the cursor
|
|
const lineIntersections = getAllIntersectionPoints(
|
|
closestSegmentInfo.entity,
|
|
getPickableEntities()
|
|
);
|
|
eraseLineSegment(
|
|
closestSegmentInfo.entity as LineEntity,
|
|
clickedPointOnShape,
|
|
lineIntersections
|
|
);
|
|
break;
|
|
}
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
|
|
setEntities(getEntities(), true); // Force a new undo state entry
|
|
return true;
|
|
}
|
|
|
|
/** EXTEND 한 번 — 누른 쪽 끝을 가장 가까운 경계까지 · 못 늘리면 false */
|
|
export function extendAt(worldMouseLocation: Point): boolean {
|
|
const target = pickEntityAt(worldMouseLocation);
|
|
if (!target) return false;
|
|
const extended = extendLineEnd(target, worldMouseLocation, getPickableEntities());
|
|
if (!extended) return false;
|
|
deleteEntities([target], false);
|
|
addEntities([extended], true);
|
|
return true;
|
|
}
|
|
|
|
const trimAt = (location: Point) => handleMouseClick(location, pickRadius());
|
|
|
|
type Ctx = { context: EraserContext; event: StateEvent };
|
|
|
|
/** 빠른 자르기 · 연장 머신 — `act(점, shift)` 가 바꿨으면 true */
|
|
function createQuickEditTool(
|
|
tool: Tool,
|
|
prompt: PromptSpec,
|
|
act: (location: Point, shift: boolean) => boolean
|
|
) {
|
|
const undoLast = assign(({ context }: Ctx) => {
|
|
if (context.made === 0) return {};
|
|
undo();
|
|
return { made: context.made - 1 };
|
|
});
|
|
const end = { target: END_STATE };
|
|
const forward = { actions: sendTo('trimErase', ({ event }: Ctx) => event) };
|
|
const takesErase = prompt.options?.some((option) => option.key === 'ERASE');
|
|
|
|
return createMachine(
|
|
{
|
|
types: {} as { context: EraserContext; events: StateEvent },
|
|
context: { made: 0, type: tool },
|
|
initial: EraserState.INIT,
|
|
// biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음)
|
|
states: <Record<string, any>>{
|
|
[EraserState.INIT]: {
|
|
always: {
|
|
actions: () => {
|
|
setShouldDrawHelpers(false);
|
|
setGhostHelperEntities([]);
|
|
},
|
|
target: EraserState.WAITING_FOR_FIRST_CLICK,
|
|
},
|
|
},
|
|
[EraserState.WAITING_FOR_FIRST_CLICK]: {
|
|
meta: { kind: 'entity', prompt },
|
|
on: {
|
|
MOUSE_CLICK: {
|
|
actions: assign(({ context, event }: Ctx) => {
|
|
const click = event as MouseClickEvent;
|
|
return act(click.worldMouseLocation, !!click.holdingShift)
|
|
? { made: context.made + 1 }
|
|
: {};
|
|
}),
|
|
},
|
|
...(takesErase ? { 'OPTION.ERASE': { target: EraserState.ERASE_PICK } } : {}),
|
|
'OPTION.UNDO': { actions: undoLast },
|
|
ENTER: end,
|
|
ESC: end,
|
|
},
|
|
},
|
|
...(takesErase
|
|
? {
|
|
[EraserState.ERASE_PICK]: {
|
|
meta: {
|
|
kind: 'selection',
|
|
prompt: { text: '지울 객체 선택', options: SELECT_OBJECTS.options },
|
|
},
|
|
entry: () => setShouldDrawHelpers(false),
|
|
invoke: {
|
|
id: 'trimErase',
|
|
src: selectToolStateMachine,
|
|
onDone: {
|
|
actions: assign(({ context }: Ctx) => {
|
|
const picked = getSelectedEntities();
|
|
if (!picked.length) return {};
|
|
rememberSelection(picked.map((entity) => entity.id));
|
|
setSelectedEntityIds([]);
|
|
deleteEntities(picked, true);
|
|
return { made: context.made + 1 };
|
|
}),
|
|
target: EraserState.WAITING_FOR_FIRST_CLICK,
|
|
},
|
|
},
|
|
on: {
|
|
MOUSE_CLICK: forward,
|
|
ENTER: forward,
|
|
DRAW: forward,
|
|
'OPTION.*': forward,
|
|
ESC: { actions: () => setSelectedEntityIds([]), target: END_STATE },
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
[END_STATE]: { type: 'final' },
|
|
},
|
|
},
|
|
{
|
|
// 고르기 자식(선택 도구)의 액션 구현을 함께 넘긴다
|
|
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
|
|
actions: { ...(selectToolStateMachine.implementations.actions as any) },
|
|
}
|
|
);
|
|
}
|
|
|
|
export const eraserToolStateMachine = createQuickEditTool(
|
|
Tool.ERASER,
|
|
TRIM_PROMPT,
|
|
(location, shift) => (shift ? extendAt(location) : trimAt(location))
|
|
);
|
|
|
|
export const extendToolStateMachine = createQuickEditTool(
|
|
Tool.EXTEND,
|
|
EXTEND_PROMPT,
|
|
(location, shift) => (shift ? trimAt(location) : extendAt(location))
|
|
);
|