- 복제 함수 cloneKeepingLook 한 곳(move-tool.helpers) — 옮기기 · 크기 · 회전은 id · 묶음까지 그대로 · 복사 · 배열은 새 id - LineEntity · ArcEntity clone 이 색 · 층 · 선 꼴 유지(폴리선 자식 포함) - 복사가 누를 때마다 원본을 한 벌씩 더 쌓던 흠 고침 - ArcEntity mirror 가 축 각도 따름(α → 2θ − α) · 그리기 끝각 = 시작각 ± 쓸어간 각(끝각 0 → 2π 온 원 흠) - DXF 내보내기 시계방향 호는 시작 · 끝 바꿔 같은 작은 호 - 시험: ArcEntity.test.ts 10 · resources/tester/cad/clone-keeps-look.test.ts 7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
370 lines
10 KiB
TypeScript
370 lines
10 KiB
TypeScript
import { type Point, Vector } from '@flatten-js/core';
|
|
import { assign, createMachine, sendTo } from 'xstate';
|
|
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS } from '../App.consts.ts';
|
|
import type { Entity } from '../entities/Entity';
|
|
import { LineEntity } from '../entities/LineEntity.ts';
|
|
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
|
import {
|
|
addEntities,
|
|
getActiveLayerId,
|
|
getSelectedEntities,
|
|
getSelectedEntityIds,
|
|
setAngleGuideOriginPoint,
|
|
setGhostHelperEntities,
|
|
setSelectedEntityIds,
|
|
setShouldDrawHelpers,
|
|
} from '../state';
|
|
import { Tool } from '../tools';
|
|
import { CopyAction } from './copy-tool.ts';
|
|
import { cloneKeepingLook } from './move-tool.helpers.ts';
|
|
import { selectToolStateMachine } from './select-tool.ts';
|
|
import type {
|
|
AbsolutePointInputEvent,
|
|
DrawEvent,
|
|
MouseClickEvent,
|
|
NumberInputEvent,
|
|
StateEvent,
|
|
TextInputEvent,
|
|
ToolContext,
|
|
} from './tool.types';
|
|
|
|
export enum ArrayState {
|
|
INIT = 'INIT',
|
|
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
|
CHECK_SELECTION = 'CHECK_SELECTION',
|
|
ASK_COPY_MODE = 'ASK_COPY_MODE',
|
|
ASK_NUMBER_OF_COPIES = 'ASK_NUMBER_OF_COPIES',
|
|
LINEAR_WAITING_FOR_START_POINT = 'LINEAR_WAITING_FOR_START_POINT',
|
|
LINEAR_WAITING_FOR_END_POINT = 'LINEAR_WAITING_FOR_END_POINT',
|
|
RADIAL_WAITING_FOR_PIVOT_POINT = 'RADIAL_WAITING_FOR_PIVOT_POINT',
|
|
RADIAL_WAITING_FOR_ANGLE = 'RADIAL_WAITING_FOR_ANGLE',
|
|
EXECUTE_COPY = 'EXECUTE_COPY',
|
|
}
|
|
|
|
export enum ArrayAction {
|
|
INIT_ARRAY_TOOL = 'INIT_ARRAY_TOOL',
|
|
PERFORM_COPY = 'PERFORM_COPY',
|
|
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
|
DRAW_TEMP_DISTANCE_LINE = 'DRAW_TEMP_DISTANCE_LINE',
|
|
}
|
|
|
|
export interface ArrayContext extends ToolContext {
|
|
copyMode: CopyMode | null;
|
|
startDistanceVector: Point | null;
|
|
endDistanceVector: Point | null;
|
|
pivotPoint: Point | null;
|
|
angleStep: number | null;
|
|
numberOfCopies: number;
|
|
}
|
|
|
|
enum CopyMode {
|
|
LINEAR = 'LINEAR',
|
|
RADIAL = 'RADIAL',
|
|
}
|
|
|
|
const initialArrayContext: ArrayContext = {
|
|
copyMode: null,
|
|
startDistanceVector: null,
|
|
endDistanceVector: null,
|
|
pivotPoint: null,
|
|
angleStep: null,
|
|
numberOfCopies: 0,
|
|
type: Tool.ARRAY,
|
|
};
|
|
|
|
export const arrayToolStateMachine = createMachine(
|
|
{
|
|
types: {} as {
|
|
context: ArrayContext;
|
|
events: StateEvent;
|
|
},
|
|
context: initialArrayContext,
|
|
initial: ArrayState.INIT,
|
|
states: {
|
|
[ArrayState.INIT]: {
|
|
description: 'Initializing the array copy tool',
|
|
always: {
|
|
actions: ArrayState.INIT,
|
|
target: ArrayState.CHECK_SELECTION,
|
|
},
|
|
},
|
|
[ArrayState.WAITING_FOR_SELECTION]: {
|
|
description: 'Select what you want to copy',
|
|
meta: {
|
|
instructions: 'Select what you want to copy, then ENTER',
|
|
},
|
|
invoke: {
|
|
id: 'selectToolInsideTheCopyTool',
|
|
src: selectToolStateMachine,
|
|
onDone: {
|
|
actions: assign(() => {
|
|
return initialArrayContext;
|
|
}),
|
|
target: ArrayState.CHECK_SELECTION,
|
|
},
|
|
},
|
|
on: {
|
|
MOUSE_CLICK: {
|
|
// Forward the event to the select tool
|
|
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
|
return event;
|
|
}),
|
|
},
|
|
ESC: {
|
|
actions: [ArrayAction.DESELECT_ENTITIES, ArrayAction.INIT_ARRAY_TOOL],
|
|
},
|
|
ENTER: {
|
|
// Forward the event to the select tool
|
|
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
|
return event;
|
|
}),
|
|
},
|
|
DRAW: {
|
|
// Forward the event to the select tool
|
|
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
|
return event;
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
[ArrayState.CHECK_SELECTION]: {
|
|
description: 'Check if there is something selected',
|
|
always: [
|
|
{
|
|
guard: () => {
|
|
return getSelectedEntityIds().length > 0;
|
|
},
|
|
target: ArrayState.ASK_COPY_MODE,
|
|
},
|
|
{
|
|
guard: () => {
|
|
return getSelectedEntityIds().length === 0;
|
|
},
|
|
target: ArrayState.WAITING_FOR_SELECTION,
|
|
},
|
|
],
|
|
},
|
|
[ArrayState.ASK_COPY_MODE]: {
|
|
meta: { instructions: 'Choose copy mode: Linear (L) or Radial (R), ENTER for Linear' },
|
|
on: {
|
|
TEXT_INPUT: {
|
|
actions: assign(({ event }) => {
|
|
return {
|
|
copyMode:
|
|
(event as TextInputEvent).value === 'R' ? CopyMode.RADIAL : CopyMode.LINEAR,
|
|
};
|
|
}),
|
|
target: ArrayState.ASK_NUMBER_OF_COPIES,
|
|
},
|
|
ENTER: {
|
|
actions: assign(() => ({
|
|
copyMode: CopyMode.LINEAR,
|
|
})),
|
|
target: ArrayState.ASK_NUMBER_OF_COPIES,
|
|
},
|
|
ESC: {
|
|
actions: ArrayAction.INIT_ARRAY_TOOL,
|
|
},
|
|
},
|
|
},
|
|
[ArrayState.ASK_NUMBER_OF_COPIES]: {
|
|
meta: { instructions: 'Enter number of copies' },
|
|
on: {
|
|
NUMBER_INPUT: [
|
|
{
|
|
guard: ({ context }) => {
|
|
return context.copyMode === CopyMode.LINEAR;
|
|
},
|
|
actions: assign(({ event }) => ({
|
|
numberOfCopies: (event as NumberInputEvent).value,
|
|
})),
|
|
target: ArrayState.LINEAR_WAITING_FOR_START_POINT,
|
|
},
|
|
{
|
|
guard: ({ context }) => {
|
|
return context.copyMode === CopyMode.RADIAL;
|
|
},
|
|
actions: assign(({ event }) => ({
|
|
numberOfCopies: (event as NumberInputEvent).value,
|
|
})),
|
|
target: ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT,
|
|
},
|
|
],
|
|
ESC: {
|
|
actions: ArrayAction.INIT_ARRAY_TOOL,
|
|
},
|
|
},
|
|
},
|
|
[ArrayState.LINEAR_WAITING_FOR_START_POINT]: {
|
|
meta: { instructions: 'Click to define the start of the distance vector' },
|
|
on: {
|
|
ABSOLUTE_POINT_INPUT: {
|
|
actions: assign(({ event }) => ({
|
|
startDistanceVector: (event as AbsolutePointInputEvent).value,
|
|
})),
|
|
target: ArrayState.EXECUTE_COPY,
|
|
},
|
|
MOUSE_CLICK: {
|
|
actions: assign(({ event }) => ({
|
|
startDistanceVector: (event as MouseClickEvent).worldMouseLocation,
|
|
})),
|
|
target: ArrayState.LINEAR_WAITING_FOR_END_POINT,
|
|
},
|
|
ESC: {
|
|
actions: ArrayAction.INIT_ARRAY_TOOL,
|
|
},
|
|
},
|
|
},
|
|
[ArrayState.LINEAR_WAITING_FOR_END_POINT]: {
|
|
meta: { instructions: 'Click to define the end of the distance vector' },
|
|
on: {
|
|
RELATIVE_POINT_INPUT: {
|
|
actions: assign(({ event, context }) => ({
|
|
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
|
})),
|
|
target: ArrayState.EXECUTE_COPY,
|
|
},
|
|
ABSOLUTE_POINT_INPUT: {
|
|
actions: assign(({ event, context }) => ({
|
|
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
|
})),
|
|
target: ArrayState.EXECUTE_COPY,
|
|
},
|
|
MOUSE_CLICK: {
|
|
actions: assign(({ event, context }) => ({
|
|
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
|
})),
|
|
target: ArrayState.EXECUTE_COPY,
|
|
},
|
|
ESC: {
|
|
actions: ArrayAction.INIT_ARRAY_TOOL,
|
|
},
|
|
DRAW: {
|
|
actions: ArrayAction.DRAW_TEMP_DISTANCE_LINE,
|
|
},
|
|
},
|
|
},
|
|
[ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT]: {
|
|
meta: { instructions: 'Click to set pivot point' },
|
|
on: {
|
|
ABSOLUTE_POINT_INPUT: {
|
|
actions: assign(({ event }) => ({
|
|
pivotPoint: (event as AbsolutePointInputEvent).value,
|
|
})),
|
|
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
|
|
},
|
|
MOUSE_CLICK: {
|
|
actions: assign(({ event }) => ({
|
|
pivotPoint: (event as MouseClickEvent).worldMouseLocation,
|
|
})),
|
|
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
|
|
},
|
|
ESC: {
|
|
actions: ArrayAction.INIT_ARRAY_TOOL,
|
|
},
|
|
},
|
|
},
|
|
[ArrayState.RADIAL_WAITING_FOR_ANGLE]: {
|
|
meta: { instructions: 'Enter angle per step (in degrees)' },
|
|
on: {
|
|
NUMBER_INPUT: {
|
|
target: ArrayState.EXECUTE_COPY,
|
|
actions: assign(({ event }) => ({
|
|
angleStep: (event as NumberInputEvent).value,
|
|
})),
|
|
},
|
|
},
|
|
},
|
|
[ArrayState.EXECUTE_COPY]: {
|
|
entry: ArrayAction.PERFORM_COPY,
|
|
always: ArrayState.INIT,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
actions: {
|
|
[ArrayAction.INIT_ARRAY_TOOL]: assign(() => {
|
|
setShouldDrawHelpers(true);
|
|
setGhostHelperEntities([]);
|
|
setSelectedEntityIds([]);
|
|
setAngleGuideOriginPoint(null);
|
|
return {
|
|
copyMode: null,
|
|
distanceVector: null,
|
|
pivotPoint: null,
|
|
angleStep: null,
|
|
numberOfCopies: 0,
|
|
selectedEntities: [],
|
|
};
|
|
}),
|
|
|
|
[ArrayAction.DRAW_TEMP_DISTANCE_LINE]: ({ context, event }) => {
|
|
if (!context.startDistanceVector) {
|
|
throw new Error('[ARRAY] Calling draw temp distance line without a start point');
|
|
}
|
|
|
|
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
|
|
|
|
// Draw the array entities to show the result
|
|
// Draw all selected entities according to distance vector and number of copies, so the user gets visual feedback of where the entities will be copied;
|
|
// TODO
|
|
|
|
// // Draw a dashed line between the start move point and the current mouse location
|
|
const activeDistanceLine = new LineEntity(
|
|
getActiveLayerId(),
|
|
context.startDistanceVector,
|
|
endPointTemp
|
|
);
|
|
activeDistanceLine.lineColor = GUIDE_LINE_COLOR;
|
|
activeDistanceLine.lineWidth = GUIDE_LINE_WIDTH;
|
|
activeDistanceLine.lineDash = GUIDE_LINE_STYLE;
|
|
setGhostHelperEntities([activeDistanceLine]);
|
|
},
|
|
|
|
[ArrayAction.PERFORM_COPY]: ({ context }) => {
|
|
const resultEntities: Entity[] = [];
|
|
|
|
for (let i = 1; i <= context.numberOfCopies; i++) {
|
|
if (
|
|
context.copyMode === 'LINEAR' &&
|
|
context.startDistanceVector &&
|
|
context.endDistanceVector
|
|
) {
|
|
const distanceVector = new Vector(
|
|
context.endDistanceVector.x - context.startDistanceVector.x,
|
|
context.endDistanceVector.y - context.startDistanceVector.y
|
|
);
|
|
const x = distanceVector.x * i;
|
|
const y = distanceVector.y * i;
|
|
resultEntities.push(
|
|
...getSelectedEntities().map((entity) => {
|
|
const clone = cloneKeepingLook(entity);
|
|
clone.move(x, y);
|
|
return clone;
|
|
})
|
|
);
|
|
} else if (context.copyMode === 'RADIAL' && context.pivotPoint && context.angleStep) {
|
|
const angleRad = context.angleStep * i * TO_RADIANS;
|
|
resultEntities.push(
|
|
...getSelectedEntities().map((entity) => {
|
|
const clone = cloneKeepingLook(entity);
|
|
clone.rotate(context.pivotPoint as Point, angleRad);
|
|
return clone;
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
addEntities(resultEntities, true);
|
|
setGhostHelperEntities([]);
|
|
setSelectedEntityIds([]);
|
|
},
|
|
[CopyAction.DESELECT_ENTITIES]: assign(() => {
|
|
setGhostHelperEntities([]);
|
|
setSelectedEntityIds([]);
|
|
return initialArrayContext;
|
|
}),
|
|
},
|
|
}
|
|
);
|