파일마다 포맷 폭이 달라(≈80 대 100) 한 줄만 고쳐도 포맷터가 무관한 줄을 대량 재포맷했음. 사용자 지시로 전체를 한 번에 맞춤. 코드 동작 변경 없음 — 포맷만. - 프론트엔드 `.ts/.css/.html` → 저장소 prettier (`.prettierrc`, printWidth 100) - `B07_DesignDetail/openwebcad/**` → 자체 biome (tab 들여쓰기·single quote·lineWidth 100). `biome format` 만 사용 — `biome lint --write` 는 포맷 아닌 코드 수정까지 하므로 제외 - 파이썬 → `ruff format` (엔진 코드는 이미 정합, resources·scratch 스크립트 24개만 변경) 두 포맷터가 서로 되돌리지 않도록 `.prettierignore` 신규 — openwebcad 와 빌드·산출물 폴더를 prettier 대상에서 뺌. `.prettierrc` 에 `endOfLine: "auto"` 추가 — 기본값 `lf` 가 `core.autocrlf=true` 로 받은 CRLF 파일을 매번 전부 다시 써서 `--list-different` 가 실제 포맷 차이를 가리고 있었음. 검증: `tsc --noEmit` 통과(루트·openwebcad 둘 다), pytest 349 passed / 17 skipped / 0 failed, CAD vitest 87건 중 81 passed / 6 failed(laptop-sub 기준선과 동일, 회귀 없음). 포맷터 재실행 시 prettier·biome 모두 변경 0건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
369 lines
10 KiB
TypeScript
369 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 { 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 = entity.clone();
|
|
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 = entity.clone();
|
|
clone.rotate(context.pivotPoint as Point, angleRad);
|
|
return clone;
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
addEntities(resultEntities, true);
|
|
setGhostHelperEntities([]);
|
|
setSelectedEntityIds([]);
|
|
},
|
|
[CopyAction.DESELECT_ENTITIES]: assign(() => {
|
|
setGhostHelperEntities([]);
|
|
setSelectedEntityIds([]);
|
|
return initialArrayContext;
|
|
}),
|
|
},
|
|
}
|
|
);
|