feat(B07,B08): 워크플로 순서 교환 — 상세설계를 수량산출 앞으로

횡단설계(B06) 다음을 상세설계 → 수량산출 → 설계도서 순으로 재배열하고,
폴더 번호가 흐름과 일치하도록 이름을 맞바꾼다.

- B08_DesignDetail → B07_DesignDetail, B07_Quantity → B08_Quantity
  (파일 접두어·식별자·라우트·locale 키 전량 스왑)
- STAGE_KEYS 4=DESIGN_DETAIL, 5=QUANTITY 스왑 + 라우터 stage 리터럴 교체
- CAD 마운트 /b08-cad → /b07-cad (main.py·vite proxy·iframe URL),
  openwebcad Toolbar 라벨 B07로 수정 후 재빌드
- 유지: openwebcad postMessage 프로토콜 aislo:b08:*·패키지명(내부 식별자)
- 기존 프로젝트 storage 폴더 rename + project_manifest 갱신,
  DB project_workflow_stages stage_no 4↔5 행 스왑 완료

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 16:16:46 +09:00
co-authored by Claude Fable 5
parent f99f1f68d3
commit 6e195afb69
199 changed files with 227 additions and 227 deletions
@@ -0,0 +1,368 @@
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;
}),
},
}
);