Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user