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:
2026-10-16 00:14:29 +09:00
co-authored by Claude Opus 5.5
parent 6e346cfb5e
commit 00a379595b
7 changed files with 1216 additions and 141 deletions
@@ -32,11 +32,11 @@ import {
extendToolStateMachine,
joinToolStateMachine,
} from '../tools/modify/structure-tools';
import { offsetToolStateMachine } from '../tools/modify/offset-tool';
import {
alignToolStateMachine,
lengthenToolStateMachine,
mirrorToolStateMachine,
offsetToolStateMachine,
stretchToolStateMachine,
} from '../tools/modify/transform-tools';
import { moveToolStateMachine } from '../tools/move-tool';
@@ -1,15 +1,68 @@
import { type Circle, Point, type Segment } from '@flatten-js/core';
import { type Circle, Point, Segment } from '@flatten-js/core';
import { compact } from 'es-toolkit';
import { ArcEntity } from '../entities/ArcEntity';
import type { CircleEntity } from '../entities/CircleEntity';
import type { Entity } from '../entities/Entity';
import type { LineEntity } from '../entities/LineEntity';
import { LineEntity } from '../entities/LineEntity';
import { findClosestEntity } from '../helpers/find-closest-entity';
import { findNeighboringPointsOnArc } from '../helpers/find-neighboring-points-on-arc';
import { findNeighboringPointsOnCircle } from '../helpers/find-neighboring-points-on-circle';
import { findNeighboringPointsOnLine } from '../helpers/find-neighboring-points-on-line';
import { sampleEntityPoints } from '../helpers/geometry/sample-entity';
import { intersectLines } from '../helpers/geometry/shape-points';
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
import { isPointEqual } from '../helpers/is-point-equal';
import { addEntities, deleteEntities, getActiveLayerId } from '../state';
import { pickRadius } from '../helpers/pick-radius';
import { queryEntitiesNearPoint } from '../helpers/spatial-index';
import { addEntities, deleteEntities, getActiveLayerId, getLayerById } from '../state';
import { copyStyle, isBetween } from './modify/modify.helpers';
/** 누른 자리 집기 반경 안 가장 가까운 객체 — 잠금 도면층 뺌(순서 엔진과 같은 셈) */
export function pickEntityAt(point: Point): Entity | null {
const radius = pickRadius();
const candidates = queryEntitiesNearPoint(point.x, point.y, radius).filter(
(entity) => !getLayerById(entity.layerId)?.isLocked
);
const { distance, entity } = findClosestEntity(point, candidates);
return entity && distance <= radius ? entity : null;
}
/** 점을 객체 위로 내림(가장 가까운 자리) */
export const closestPointOn = (entity: Entity, point: Point): Point =>
findClosestEntity(point, [entity]).segment?.start ?? point;
/** EXTEND 빠른 모드 — 누른 쪽 끝을 그 방향 가장 가까운 경계(다른 모든 객체)까지 · 선만 */
export function extendLineEnd(line: Entity, pick: Point, boundaries: Entity[]): Entity | null {
const shape = line.getShape();
if (!(shape instanceof Segment)) return null;
const fromStart = pick.distanceTo(shape.start)[0] < pick.distanceTo(shape.end)[0];
const tip = fromStart ? shape.start : shape.end;
const tail = fromStart ? shape.end : shape.start;
const length = tail.distanceTo(tip)[0];
if (!length) return null;
const dx = (tip.x - tail.x) / length;
const dy = (tip.y - tail.y) / length;
let best = Number.POSITIVE_INFINITY;
let hit: Point | null = null;
for (const boundary of boundaries) {
if (boundary.id === line.id) continue;
const points = sampleEntityPoints(boundary);
for (let index = 1; index < points.length; index++) {
const crossing = intersectLines(tail, tip, points[index - 1], points[index]);
if (!crossing || !isBetween(crossing, points[index - 1], points[index])) continue;
const ahead = (crossing.x - tip.x) * dx + (crossing.y - tip.y) * dy;
if (ahead > 1e-9 && ahead < best) {
best = ahead;
hit = crossing;
}
}
}
if (!hit) return null;
return copyStyle(
line,
new LineEntity(line.layerId, fromStart ? hit : tail, fromStart ? tail : hit)
);
}
export function getAllIntersectionPoints(entity: Entity, entities: Entity[]): Point[] {
// TODO see if we need to make this list unique
@@ -39,8 +92,10 @@ export function eraseLineSegment(
);
const cutLines: Entity[] = line.cutAtPoints([firstCutPoint, secondCutPoint]);
// Remove the segment that has the clickedPointOnShape point on it
const remainingLines = cutLines.filter((line) => !line.containsPointOnShape(clickedPointOnShape));
// Remove the segment that has the clickedPointOnShape point on it — 남는 토막은 원래 층 · 꼴(N9)
const remainingLines = cutLines
.filter((piece) => !piece.containsPointOnShape(clickedPointOnShape))
.map((piece) => copyStyle(line, piece));
deleteEntities([line], false);
// Helper functions should never trigger an undo state, since they can be called multiple times during one user operation
@@ -1,100 +1,91 @@
/**
* TRIM · EXTEND 빠른 모드(337 설계 1-2) — 누를 때마다 자름(연장) · Shift+누름 = 반대 일 · 되풀이 ·
* 빈 Enter · Esc = 끝 · 집기 반경 밖 누름은 아무 일 없음(N9) · U = 이 명령 안 마지막 하나 ·
* TRIM 지우기(R) = 고르기 → Enter 로 지우고 돌아옴.
*/
import type { Point, Polygon } from '@flatten-js/core';
import { assign, createMachine } from 'xstate';
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,
getActiveLayerId,
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 type { MouseClickEvent, StateEvent, ToolContext } from './tool.types';
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 {
startPoint: Point | null;
/** 이 명령에서 바꾼 횟수(U) */
made: number;
}
export enum EraserState {
INIT = 'INIT',
WAITING_FOR_FIRST_CLICK = 'WAITING_FOR_FIRST_CLICK',
ERASE_PICK = 'ERASE_PICK',
}
export enum EraserAction {
INIT_ERASER_TOOL = 'INIT_ERASER_TOOL',
HANDLE_MOUSE_CLICK = 'HANDLE_MOUSE_CLICK',
}
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', '투영');
export const eraserToolStateMachine = createMachine(
{
types: {} as {
context: EraserContext;
events: StateEvent;
},
context: {
startPoint: null,
type: Tool.ERASER,
},
initial: EraserState.INIT,
states: {
[EraserState.INIT]: {
description: 'Initializing the eraser tool',
always: {
actions: EraserAction.INIT_ERASER_TOOL,
target: EraserState.WAITING_FOR_FIRST_CLICK,
},
},
[EraserState.WAITING_FOR_FIRST_CLICK]: {
description: '지울 선 선택',
meta: {
instructions: '지울 선 선택',
},
on: {
// TODO implement a DRAW action to draw the segment that will be erased in dotted line
MOUSE_CLICK: {
actions: EraserAction.HANDLE_MOUSE_CLICK,
target: EraserState.WAITING_FOR_FIRST_CLICK,
},
},
},
// TODO implement rectangle selection to delete
},
},
{
actions: {
[EraserAction.INIT_ERASER_TOOL]: assign(() => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
return {};
}),
[EraserAction.HANDLE_MOUSE_CLICK]: assign(({ context, event }) => {
handleMouseClick((event as MouseClickEvent).worldMouseLocation);
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],
};
return context;
}),
},
}
);
export function handleMouseClick(worldMouseLocation: Point) {
/** TRIM 한 번 — 누른 자리 반경 안 객체만(없으면 false) · 사각형 변은 원래 층 · 꼴 */
export function handleMouseClick(
worldMouseLocation: Point,
radius = Number.POSITIVE_INFINITY
): boolean {
const closestEntity = findClosestEntity(worldMouseLocation, getPickableEntities());
if (!closestEntity) {
return;
if (!closestEntity.entity || closestEntity.distance > radius) {
return false;
}
const clickedPointOnShape = closestEntity.segment.start;
@@ -125,8 +116,8 @@ export function handleMouseClick(worldMouseLocation: Point) {
case EntityName.Rectangle: {
const rectangle = closestEntity.entity as RectangleEntity;
const segments = polygonToSegments(rectangle.getShape() as Polygon);
const segmentEntities = segments.map(
(segment) => new LineEntity(getActiveLayerId(), segment)
const segmentEntities = segments.map((segment) =>
copyStyle(rectangle, new LineEntity(rectangle.layerId, segment))
);
// Find the closest segment to the clicked point
@@ -151,11 +142,129 @@ export function handleMouseClick(worldMouseLocation: Point) {
break;
}
case EntityName.Image: {
// TODO implement image eraser
// Switch to rectangle entity and delete the image and the line that was closest to the cursor
}
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))
);
@@ -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);
+374
View File
@@ -0,0 +1,374 @@
/**
* 337 다-1 고치기 7 — OFFSET · FILLET · CHAMFER · TRIM · EXTEND · BREAK · JOIN 을 오토캐드 물음 차례 · 옵션 · 기본값 ·
* 빈 Enter · Esc · 끝난 뒤 동작으로(설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-2 · 6-3 다 줄).
* 기억(OFFSETDIST · FILLETRAD · CHAMFERA/B · TRIMMODE)은 파일 안 세션 하나 — 시험 차례대로 이어짐.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad modify-acad
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-toastify", () => ({
toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }),
}));
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
import { activeTool, click, entities, initIdle, key, promptLine, type } from "./helpers/cad-drive";
type Shape = { start: { x: number; y: number }; end: { x: number; y: number } };
const enter = () => key("Enter");
const r = (value: number) => Math.round(value * 1e4) / 1e4;
/** 선 하나 — 쉼에서 그리고 Esc 로 끝 */
const line = (a: string, b: string) => {
type("l");
type(a);
type(b);
key("Escape");
};
/** 선마다 [x1, y1, x2, y2] — 차례 무관(정렬) */
const segs = () =>
entities("Line")
.map((entity) => {
const { start, end } = entity.getShape() as unknown as Shape;
return [r(start.x), r(start.y), r(end.x), r(end.y)];
})
.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3]);
const ys = () =>
segs()
.map((s) => s[1])
.sort((a, b) => a - b);
beforeEach(() => {
initIdle();
});
describe("OFFSET", () => {
it("처음 <통과점> · 통과점 · 되풀이 · 거리 기억 · T · 빈 Enter 끝", () => {
line("0,0", "100,0");
type("o");
expect(activeTool()).toBe(Tool.OFFSET);
expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:");
enter(); // <통과점>
expect(promptLine()).toBe("간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:");
click(50, 0);
expect(promptLine()).toBe("통과점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:");
type("30,20");
expect(ys()).toEqual([0, 20]);
expect(promptLine()).toBe("간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:"); // 되풀이
key("Escape");
expect(activeTool()).toBe(Tool.SELECT);
type("o");
expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:"); // 통과점 기억
type("0");
expect(activeTool()).toBe(Tool.OFFSET); // 0 거절
type("5");
click(50, 0);
expect(promptLine()).toBe(
"간격띄우기할 면의 점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:",
);
type("50,-10");
click(50, 20);
type("50,30");
expect(ys()).toEqual([-5, 0, 20, 25]);
enter(); // <종료>
expect(activeTool()).toBe(Tool.SELECT);
type("o");
expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <5.0000>:");
type("t");
click(50, 25);
expect(promptLine()).toBe("통과점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:");
type("e"); // 종료
expect(activeTool()).toBe(Tool.SELECT);
type("o");
expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:");
key("Escape");
});
it("지우기(E) 예 · 다중(M) · 명령 취소(U) · 없는 옵션 L 안내", () => {
line("0,0", "100,0");
type("o");
type("l");
expect(activeTool()).toBe(Tool.OFFSET); // 도면층(L) = 이 CAD 에 없음
type("e");
expect(promptLine()).toBe(
"간격띄우기 후 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:",
);
type("y");
type("10");
click(50, 0);
type("50,5");
expect(ys()).toEqual([10]); // 원본 지움
key("Escape");
type("o");
type("e");
expect(promptLine()).toBe("간격띄우기 후 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <예>:");
type("n");
enter(); // <10.0000>
click(50, 10);
type("m");
expect(promptLine()).toBe("간격띄우기할 면의 점 지정 또는 [종료(E)/명령 취소(U)] <다음 객체>:");
type("50,100");
type("50,100");
expect(ys()).toEqual([10, 20, 30]); // 방금 만든 것에서 되풀이 · 원본 둠
type("u");
expect(ys()).toEqual([10, 20]);
expect(promptLine()).toBe("간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:");
type("u");
type("u"); // 이 명령에서 만든 것 없음 = 그대로
expect(ys()).toEqual([10]);
enter();
expect(activeTool()).toBe(Tool.SELECT);
});
});
/** ㄱ 자 — 가로 (0,0)~(100,0) · 세로 (80,−20)~(80,100) · 구석 (80, 0) */
const corner = () => {
line("0,0", "100,0");
line("80,-20", "80,100");
};
describe("FILLET", () => {
it("물음 글 · R 기억 · 쌍 하나 뒤 끝", () => {
corner();
type("f");
expect(promptLine()).toBe("첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:");
type("r");
expect(promptLine()).toBe("모깎기 반지름 지정 <0.0000>:");
type("10");
expect(promptLine()).toBe("첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:");
click(30, 0);
expect(promptLine()).toBe(
"두 번째 객체 선택 또는 Shift 키를 누른 채 선택하여 구석 적용 또는 [반지름(R)]:",
);
click(80, 60);
expect(segs()).toEqual([
[0, 0, 70, 0],
[80, 100, 80, 10],
]);
expect(entities("Arc")).toHaveLength(1);
expect(activeTool()).toBe(Tool.SELECT);
type("f");
type("r");
expect(promptLine()).toBe("모깎기 반지름 지정 <10.0000>:");
key("Escape");
expect(activeTool()).toBe(Tool.SELECT);
});
it("다중(M) · Shift = 반지름 0 구석 · 명령 취소(U) · 자르기(T) 자르지 않기 · 빈 Enter 끝", () => {
corner();
const before = segs();
type("f");
type("m");
click(30, 0);
click(80, 60, { shiftKey: true });
expect(segs()).toEqual([
[0, 0, 80, 0],
[80, 100, 80, 0],
]);
expect(entities("Arc")).toHaveLength(0);
expect(promptLine()).toBe("첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:"); // 다중 되풀이
type("u");
expect(segs()).toEqual(before);
type("t");
expect(promptLine()).toBe("자르기 모드 옵션 입력 [자르기(T)/자르지 않기(N)] <자르기>:");
type("n");
click(30, 0);
click(80, 60);
expect(segs()).toEqual(before); // 안 자름
expect(entities("Arc")).toHaveLength(1);
enter();
expect(activeTool()).toBe(Tool.SELECT);
type("f");
type("t");
expect(promptLine()).toBe("자르기 모드 옵션 입력 [자르기(T)/자르지 않기(N)] <자르지 않기>:");
type("t");
key("Escape");
});
});
describe("CHAMFER", () => {
it("D 기억(둘째 기본 = 첫째) · 모따기 선 · 한 번 뒤 끝", () => {
corner();
type("cha");
expect(activeTool()).toBe(Tool.CHAMFER);
expect(promptLine()).toBe("첫 번째 선 선택 또는 [명령 취소(U)/거리(D)/자르기(T)/다중(M)]:");
type("d");
expect(promptLine()).toBe("첫 번째 모따기 거리 지정 <0.0000>:");
type("10");
expect(promptLine()).toBe("두 번째 모따기 거리 지정 <10.0000>:");
enter();
click(30, 0);
expect(promptLine()).toBe(
"두 번째 선 선택 또는 Shift 키를 누른 채 선택하여 구석 적용 또는 [거리(D)]:",
);
click(80, 60);
expect(segs()).toEqual([
[0, 0, 70, 0],
[70, 0, 80, 10],
[80, 100, 80, 10],
]);
expect(activeTool()).toBe(Tool.SELECT);
type("cha");
type("d");
expect(promptLine()).toBe("첫 번째 모따기 거리 지정 <10.0000>:");
type("4");
expect(promptLine()).toBe("두 번째 모따기 거리 지정 <4.0000>:");
key("Escape");
});
it("Shift = 거리 0 구석", () => {
corner();
type("cha");
click(30, 0);
click(80, 60, { shiftKey: true });
expect(segs()).toEqual([
[0, 0, 80, 0],
[80, 100, 80, 0],
]);
});
});
describe("TRIM", () => {
/** 가로 (0,0)~(100,0) · 세로 둘 x = 30 · 70 */
const fence = () => {
line("0,0", "100,0");
line("30,-20", "30,20");
line("70,-20", "70,20");
};
it("되풀이 · 반경 밖 무시 · U · 지우기(R) · 빈 Enter 끝", () => {
fence();
type("tr");
expect(activeTool()).toBe(Tool.ERASER);
expect(promptLine()).toBe(
"자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장 또는 [지우기(R)]:",
);
click(50, 0);
expect(segs()).toContainEqual([0, 0, 30, 0]);
expect(segs()).toContainEqual([70, 0, 100, 0]);
expect(segs()).toHaveLength(4);
click(50, 50); // 집기 반경 밖
expect(segs()).toHaveLength(4);
type("u");
expect(segs()).toContainEqual([0, 0, 100, 0]);
click(50, 0);
click(30, 15);
expect(segs()).toContainEqual([30, -20, 30, 0]);
expect(activeTool()).toBe(Tool.ERASER); // 되풀이
type("r");
expect(promptLine()).toBe("지울 객체 선택:");
click(70, 10);
enter();
expect(segs().some((s) => s[0] === 70 && s[2] === 70)).toBe(false);
expect(promptLine()).toBe(
"자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장 또는 [지우기(R)]:",
);
type("c");
expect(activeTool()).toBe(Tool.ERASER); // 걸치기(C) = 이 CAD 에 없음
enter();
expect(activeTool()).toBe(Tool.SELECT);
});
it("Esc 한 번 = 끝 · Shift = 연장", () => {
line("0,0", "50,0");
line("80,-20", "80,20");
type("tr");
click(40, 0, { shiftKey: true });
expect(segs()).toContainEqual([0, 0, 80, 0]);
key("Escape");
expect(activeTool()).toBe(Tool.SELECT);
});
it("사각형 변 = 원래 도면층 · 꼴(N9)", () => {
type("rec");
type("0,0");
type("100,40");
key("Escape");
const rectangle = entities("Rectangle")[0];
rectangle.layerId = "L2";
rectangle.lineColor = "#ff0000";
line("20,-10", "20,50");
type("tr");
click(60, 40);
const sides = entities("Line").filter((entity) => entity.layerId === "L2");
expect(sides.length).toBe(4);
expect(sides.every((entity) => entity.lineColor === "#ff0000")).toBe(true);
expect(segs()).toContainEqual([0, 40, 20, 40]);
});
});
describe("EXTEND", () => {
it("빠른 연장 · 되풀이 · Shift = 자르기 · 빈 Enter 끝", () => {
line("0,0", "50,0");
line("80,-20", "80,20");
type("ex");
expect(activeTool()).toBe(Tool.EXTEND);
expect(promptLine()).toBe("연장할 객체 선택 또는 Shift 키를 누른 채 선택하여 자르기:");
click(40, 0);
expect(segs()).toContainEqual([0, 0, 80, 0]);
click(80, 10, { shiftKey: true });
expect(segs()).toContainEqual([80, -20, 80, 0]);
expect(activeTool()).toBe(Tool.EXTEND);
enter();
expect(activeTool()).toBe(Tool.SELECT);
});
});
describe("BREAK", () => {
it("누른 점 = 첫 끊기점 · 둘째 점 · 한 번 뒤 끝", () => {
line("0,0", "100,0");
type("br");
expect(promptLine()).toBe("객체 선택:");
click(20, 0);
expect(promptLine()).toBe("두 번째 끊기점 지정 또는 [첫 번째 점(F)]:");
type("60,0");
expect(segs()).toEqual([
[0, 0, 20, 0],
[60, 0, 100, 0],
]);
expect(activeTool()).toBe(Tool.SELECT);
});
it("첫 번째 점(F) · `@0,0` = 같은 점(틈 없이 둘)", () => {
line("0,0", "100,0");
type("br");
click(50, 0);
type("f");
expect(promptLine()).toBe("첫 번째 끊기점 지정:");
type("10,0");
expect(promptLine()).toBe("두 번째 끊기점 지정:");
type("30,0");
expect(segs()).toEqual([
[0, 0, 10, 0],
[30, 0, 100, 0],
]);
type("br");
click(60, 0);
type("@0,0");
expect(segs()).toEqual([
[0, 0, 10, 0],
[30, 0, 60, 0],
[60, 0, 100, 0],
]);
});
});
describe("JOIN", () => {
it("고르기 물음 글 · 빈 Enter = 결합 · 한 번 뒤 끝", () => {
line("0,0", "40,0");
line("40,0", "40,40");
type("j");
expect(promptLine()).toBe("한 번에 결합할 원본 객체 또는 여러 객체 선택:");
click(20, 0);
click(40, 20);
enter();
expect(entities("Line")).toHaveLength(0);
expect(entities("PolyLine")).toHaveLength(1);
expect(activeTool()).toBe(Tool.SELECT);
});
});