From 00a379595b0c5e3299f08165967731725b457f5d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 00:14:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(CAD):=20337-4a=20=EB=8B=A4-1=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=98=EA=B8=B0=207=20=E2=80=94=20OFFSET=20=C2=B7=20FILLET?= =?UTF-8?q?=20=C2=B7=20CHAMFER=20=C2=B7=20TRIM=20=C2=B7=20EXTEND=20=C2=B7?= =?UTF-8?q?=20BREAK=20=C2=B7=20JOIN=20=EC=98=A4=ED=86=A0=EC=BA=90=EB=93=9C?= =?UTF-8?q?=20=EB=AC=BC=EC=9D=8C=20=EC=B0=A8=EB=A1=80=20=C2=B7=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=C2=B7=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=C2=B7=20=EB=B9=88=20Enter=20=C2=B7=20Esc=20=C2=B7?= =?UTF-8?q?=20=EB=81=9D=EB=82=9C=20=EB=92=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../src/commands/commands.modify.ts | 2 +- .../src/tools/eraser-tool.helpers.ts | 65 ++- .../openwebcad/src/tools/eraser-tool.ts | 243 +++++++--- .../src/tools/modify/corner-tools.ts | 420 ++++++++++++++++-- .../src/tools/modify/offset-tool.ts | 219 +++++++++ .../src/tools/modify/structure-tools.ts | 34 +- resources/tester/cad/modify-acad.test.ts | 374 ++++++++++++++++ 7 files changed, 1216 insertions(+), 141 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/tools/modify/offset-tool.ts create mode 100644 resources/tester/cad/modify-acad.test.ts diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts index 14e078a66..1efeadeaf 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts @@ -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'; diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts index 9238c284c..50475a2a9 100644 --- a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts index 933a54d2e..ca2746b20 100644 --- a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts @@ -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: >{ + [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)) +); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts index 425b352a4..d971a1ea6 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts @@ -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('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: >{ + 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('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('CHAMFERA') ?? 0; + const b = recall('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' }, }, }); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/offset-tool.ts b/B07_DesignDetail/openwebcad/src/tools/modify/offset-tool.ts new file mode 100644 index 000000000..2d2a0d9ae --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/offset-tool.ts @@ -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): 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 => { + 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 => { + 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 = { + 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, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts index b49e1bd3f..a5535b388 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts @@ -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); diff --git a/resources/tester/cad/modify-acad.test.ts b/resources/tester/cad/modify-acad.test.ts new file mode 100644 index 000000000..844fafe23 --- /dev/null +++ b/resources/tester/cad/modify-acad.test.ts @@ -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); + }); +});