Files
Aislo/B07_DesignDetail/openwebcad/src/state.ts
T
eomsangdonandClaude Fable 5 17e14a25ed fix(B07): 편집이 사라지지 않게 하고 확정한 도면을 잠근다
사용자 편의성 점검 15건 중 반영 확정분 12건.

편집 손실 방지
- CAD에 미저장 표시(drawingDirty)를 두고, 도면을 바꾸기 전에 묻는다.
  전에는 그은 선이 경고 없이 사라졌다(실측: 캔버스 서명이 원본과 동일).
- 도면 전환을 겹쳐 눌러도 늦게 온 응답이 화면을 덮지 않게 가드를 뒀다.
- 도면층 삭제·객체 이동을 되돌리기에 실어 Ctrl+Z로 돌아오게 했다.

확정한 도면은 읽기 전용
- runCommand 한 곳에서 보는 명령만 통과시킨다(허용 명시 방식).
- 도면을 실을 때 앞 도면의 그리기 도구를 내린다 — 켜 둔 도구가 확정본에도
  계속 그렸다.
- 확정 버튼 자리를 [현재 도면 확정] / [수정]으로 가른다. 확정 해제는 [수정]
  한 곳뿐 — 되돌리기·색 고르기로 확정이 풀리던 경로를 없앴다.

그 밖
- 잠금 도면층(도각·등고선·계류)은 마우스가 스쳐도 하이라이트하지 않는다.
- 준비 중 도면 7종도 빈 도각으로 열린다(눌리지 않는 회색 버튼 제거).
- 도면층 행이 잘리지 않게 패널을 300px로 넓히고 이름을 줄여 담는다.
- 기본 그리기 색을 검정으로 — 흰 종이에 흰 선이라 안 보였다.
- 색 고르는 동안 변경 통지가 연발하지 않는다.
- 수량표는 앞 단계 산출물이라 도면에서 고치지 못하게 막고 안내한다.
- 자동백업 칸을 도면별로 나눈다 — 되살리면 다른 도면에 붙었다.
- 횡단 장 id를 시작 측점 기준으로 바꿔 구간이 달라져도 옛 확정이 안 붙는다.
- 회사 도각 저장 전에 좌표를 검사하고, 기본 도각으로 되돌리기를 연다.
- 횡단 파일이 없어도 나머지 도면 목록은 남는다.
- 불러오는 중·실패를 화면에 표시한다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:05:22 +09:00

582 lines
20 KiB
TypeScript

import type { Point } from '@flatten-js/core';
import { isEqual } from 'es-toolkit';
import { toast } from 'react-toastify';
import type { Actor, MachineSnapshot } from 'xstate';
import {
type DesignMeta,
type HoverPoint,
HtmlEvent,
type Layer,
type SnapPoint,
type StateMetaData,
} from './App.types';
import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
import type { Entity } from './entities/Entity';
import { bumpSceneVersion } from './helpers/scene-version';
import { StateVariable, type UndoState, createStack } from './helpers/undo-stack';
import type { InputController } from './inputController/input-controller.ts'; // state variables
// state variables
/**
* Canvas element
*/
let canvas: HTMLCanvasElement | null = null;
/**
* Active tool xstate actor
*/
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
let activeToolActor: Actor<any> | null = null;
/**
* Last state instructions
*/
let lastStateInstructions: string | null = null;
/**
* List of entities like lines, circles, rectangles, etc to be drawn on the canvas
*/
let entities: Entity[] = [];
/**
* Entities that are highlighted: when the mouse is close to an entity
*/
let highlightedEntityIds: string[] = [];
let highlightedEntityIdSet: Set<string> = new Set();
/**
* Entities that are selected by the user by clicking on them with the select tool or by selecting them with a selection rectangle
*/
let selectedEntityIds: string[] = [];
let selectedEntityIdSet: Set<string> = new Set();
/**
* Whether to draw the cursor or not
*/
let shouldDrawCursor = false;
/**
* Angle guide temporary entities, these are recalculated every frame as the user moves their mouse during a draw action
*/
let angleGuideEntities: Entity[] = [];
/**
* These are entities that are being drawn on the canvas during a move, scale or rotate operation
* To give visual feedback to the user of the final result
*/
let ghostHelperEntities: Entity[] = [];
/**
* Should helper entities be calculated and drawn? eg: angle guides and snap points
*/
let shouldDrawHelpers = false;
/**
* Entities that are drawn for debugging the application purposes
*/
let debugEntities: Entity[] = [];
/**
* Angle step for angle guide. Can be changes by the user using the angle step buttons
*/
let angleStep = 45;
/**
* Draw controller to draw lines to the screen while taking zoom level and screen offset into account
* We use a drawController, so we can reuse draw logic of the entities for printing to PDF and possibly more formats in the future
*/
let screenCanvasDrawController: ScreenCanvasDrawController | null = null;
/**
* Class object to manage keyboard input while drawing
* It also draws the inputted text to the canvas, next to the cursor
*/
let inputController: InputController | null = null;
/**
* Location where the user started dragging their mouse
* Used for panning the screen
*/
let panStartLocation: Point | null = null;
/**
* Entity snap point like endpoint of a line or mid-point of a line or circle center point or the intersection of 2 lines
*/
let snapPoint: SnapPoint | null = null;
/**
* Snap point on angle guide
*/
let snapPointOnAngleGuide: SnapPoint | null = null;
/**
* Last drawn point of an entity that is being drawn to be used as angle guide origin
*/
let angleGuideOriginPoint: Point | null = null;
/**
* Snap points that are hovered for a certain amount of time
*/
let hoveredSnapPoints: HoverPoint[] = [];
/**
* Timestamp of the last draw call
*/
let lastDrawTimestamp: DOMHighResTimeStamp = 0;
/**
* Active line color (7-char hex so <input type="color"> can consume it directly)
*
* 종이 배경(흰색)에 그리므로 검정이 기본이다. 원본 OpenWebCAD의 흰색을 그대로 두면
* 그은 선도 색 스와치도 흰 바탕에 묻혀 보이지 않는다(2026-09-01 실측).
*/
let activeLineColor = '#000000';
/**
* Active line width
*/
let activeLineWidth = 1;
/**
* Active line dash pattern (screen px). undefined → solid line
*/
let activeLineDash: number[] | undefined = undefined;
/**
* Active text style defaults applied to newly created text and selected text entities
*/
let activeTextStyle = {
fontFamily: 'Noto Sans KR',
fontSize: 16,
textColor: '#000000', // 흰 종이 배경 — activeLineColor와 같은 이유로 검정이 기본이다
bold: false,
italic: false,
};
/**
* Layers that can contain entities
*/
let layers: Layer[] = [
{
id: crypto.randomUUID(),
isLocked: false,
isVisible: true,
name: '기본',
},
];
/**
* Id of the currently active layer where newly drawn entities will be added to
*/
let activeLayerId: string = layers[0].id;
/**
* layerId → Layer lookup, kept in sync with `layers` (drawEntities runs this
* lookup once per entity per frame — a linear find() was a hot spot)
*/
let layersById: Map<string, Layer> = new Map(layers.map((layer) => [layer.id, layer]));
let snapEnabled = true;
let gridEnabled = false;
/** 객체 스냅 추적 — 스냅점에 머물면 그 점에서 정렬 가이드를 뻗는다 (AutoCAD F11) */
let snapTrackingEnabled = true;
/**
* 부모(B08 페이지)에서 넘어온 설계 컨텍스트. 수량 산출 패널이 이 값을 읽어
* 제목·측점정보·확정상태·수량표를 렌더한다. null이면 패널을 숨긴다.
*/
let designMeta: DesignMeta | null = null;
/**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
* 없으면 사용자가 그은 선이 경고 없이 사라진다(2026-09-01 실측).
* 도면 적재와 저장 응답에서 내려간다.
*/
let drawingDirty = false;
// getters
export const getCanvas = () => canvas;
export const getActiveToolActor = () => activeToolActor;
export const getLastStateInstructions = () => lastStateInstructions;
export const getEntities = (): Entity[] => entities;
/**
* 집을 수 있는 객체 — 잠금 도면층(도각 b08-frame·원지반 b08-ground 등 참조용)을 뺀다.
* 선택·지우기·수정 도구의 대상 찾기와 화면맞춤은 이 목록을 쓴다. AutoCAD가 도각을
* 배치(도면공간)에 두어 모형공간 작업에 끼지 않게 하는 것과 같은 자리다.
*/
export const getPickableEntities = (): Entity[] =>
entities.filter((entity) => !layersById.get(entity.layerId)?.isLocked);
export const getSelectedEntityIds = () => selectedEntityIds;
export const getShouldDrawCursor = () => shouldDrawCursor;
export const getAngleGuideEntities = () => angleGuideEntities;
export const getGhostHelperEntities = () => ghostHelperEntities;
export const getShouldDrawHelpers = () => shouldDrawHelpers;
export const getDebugEntities = () => debugEntities;
export const getAngleStep = () => angleStep;
export const getPanStartLocation = () => panStartLocation;
export const getSnapPoint = () => snapPoint;
export const getSnapPointOnAngleGuide = () => snapPointOnAngleGuide;
export const getAngleGuideOriginPoint = () => angleGuideOriginPoint;
export const getHoveredSnapPoints = () => hoveredSnapPoints;
export const getLastDrawTimestamp = () => lastDrawTimestamp;
export const getActiveLineColor = () => activeLineColor;
export const getActiveLineWidth = () => activeLineWidth;
export const getActiveLineDash = () => activeLineDash;
export const getActiveTextStyle = () => activeTextStyle;
export const getScreenCanvasDrawController = (): ScreenCanvasDrawController => {
if (!screenCanvasDrawController) {
throw new Error('getScreenCanvasDrawController() returned null');
}
return screenCanvasDrawController;
};
export const getInputController = (): InputController => {
if (!inputController) {
throw new Error('getInputController() returned null');
}
return inputController;
};
export const getSelectedEntities = (): Entity[] => {
return entities.filter((e) => selectedEntityIdSet.has(e.id));
};
export const getNotSelectedEntities = (): Entity[] => {
return entities.filter((e) => !selectedEntityIdSet.has(e.id));
};
export const isEntitySelected = (entity: Entity) => selectedEntityIdSet.has(entity.id);
export const isEntityHighlighted = (entity: Entity) => highlightedEntityIdSet.has(entity.id);
export const getHighlightedEntityIds = () => highlightedEntityIds;
export const getLayers = () => {
return layers;
};
export const getLayerById = (layerId: string): Layer | undefined => layersById.get(layerId);
export const getActiveLayerId = (): string => {
return activeLayerId;
};
export const getSnapEnabled = () => snapEnabled;
export const getGridEnabled = () => gridEnabled;
export const getSnapTrackingEnabled = () => snapTrackingEnabled;
export const getDesignMeta = (): DesignMeta | null => designMeta;
export const isDrawingDirty = () => drawingDirty;
/**
* 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
*/
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true;
// setters
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
canvas = newCanvas;
};
export const setActiveToolActor = (
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
newToolActor: Actor<any>,
triggerReact = true
) => {
const oldToolActor = getActiveToolActor();
oldToolActor?.stop();
activeToolActor = newToolActor;
activeToolActor.subscribe({
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
next: (state: MachineSnapshot<any, any, any, any, any, any, any, any>) => {
const stateInstructions = Object.values(state?.getMeta() as Record<string, StateMetaData>)[0]
?.instructions;
if (getLastStateInstructions() === stateInstructions) {
return;
}
setLastStateInstructions(stateInstructions || null);
},
error: (err) => {
toast.error(
`Error in tool actor: ${
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
(err as any)?.message || 'unknown error'
}`
);
console.error('Error in tool actor', { err, newToolActor });
},
});
activeToolActor.start();
console.log('User clicked on tool: ', {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
activeTool: (activeToolActor.src as any).config.context.type,
});
if (triggerReact) {
triggerReactUpdate(StateVariable.activeTool);
}
};
export const setLastStateInstructions = (newInstructions: string | null) => {
lastStateInstructions = newInstructions;
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => {
if (trackInUndoStack) {
trackUndoState(StateVariable.entities, newEntities);
}
entities = newEntities;
bumpSceneVersion();
if (trackInUndoStack) {
drawingDirty = true;
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
}
};
/** 도면을 새로 실었거나 저장했다 — 미저장 표시를 내린다. */
export const clearDrawingDirty = () => {
drawingDirty = false;
};
export const setHighlightedEntityIds = (newEntityIds: string[]) => {
// 잠금 도면층(도각·등고선·계류·원지반)은 배경이다 — 마우스가 스쳐도 밝아지지 않는다
// (2026-09-01 사용자 지시). 거르는 자리를 여기 한 곳에 둬 호출부가 늘어도 새지 않게 한다.
const locked = new Set(
entities.filter((entity) => layersById.get(entity.layerId)?.isLocked).map((entity) => entity.id)
);
if (locked.size) newEntityIds = newEntityIds.filter((id) => !locked.has(id));
highlightedEntityIds = newEntityIds;
highlightedEntityIdSet = new Set(newEntityIds);
};
export const setSelectedEntityIds = (newEntityIds: string[]) => {
// 잠금 도면층 객체는 어느 경로로도 선택되지 않는다 — 선택이 곧 삭제·이동 대상이라
// 호출부(Ctrl+A 등)마다 거르지 않고 여기 한 곳에서 막는다.
const locked = new Set(
entities.filter((entity) => layersById.get(entity.layerId)?.isLocked).map((entity) => entity.id)
);
if (locked.size) newEntityIds = newEntityIds.filter((id) => !locked.has(id));
selectedEntityIds = newEntityIds;
selectedEntityIdSet = new Set(newEntityIds);
bumpSceneVersion(); // selection style (dashed) is baked into the scene cache
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
export const setShouldDrawCursor = (newValue: boolean) => {
shouldDrawCursor = newValue;
};
export const setAngleGuideEntities = (newAngleGuideEntities: Entity[]) => {
angleGuideEntities = newAngleGuideEntities;
};
export const setGhostHelperEntities = (newGhostHelperEntities: Entity[]) => {
ghostHelperEntities = newGhostHelperEntities;
};
export const setShouldDrawHelpers = (shouldDraw: boolean) => {
setSnapPoint(null);
setSnapPointOnAngleGuide(null);
setAngleGuideEntities([]);
shouldDrawHelpers = shouldDraw;
};
export const setDebugEntities = (newDebugEntities: Entity[]) => {
debugEntities = newDebugEntities;
};
export const setAngleStep = (newStep: number, triggerReact = true) => {
angleStep = newStep;
if (triggerReact) {
triggerReactUpdate(StateVariable.activeTool);
}
};
export const setScreenCanvasDrawController = (
newScreenCanvasDrawController: ScreenCanvasDrawController
) => {
screenCanvasDrawController = newScreenCanvasDrawController;
};
export const setInputController = (newInputController: InputController) => {
inputController = newInputController;
};
export const setPanStartLocation = (newLocation: Point | null) => {
panStartLocation = newLocation;
};
export const setSnapPoint = (newSnapPoint: SnapPoint | null) => {
snapPoint = newSnapPoint;
};
export const setSnapPointOnAngleGuide = (newSnapPointOnAngleGuide: SnapPoint | null) => {
snapPointOnAngleGuide = newSnapPointOnAngleGuide;
};
export const setAngleGuideOriginPoint = (newAngleGuideOriginPoint: Point | null) => {
angleGuideOriginPoint = newAngleGuideOriginPoint;
};
export const setHoveredSnapPoints = (newHoveredSnapPoints: HoverPoint[]) => {
hoveredSnapPoints = newHoveredSnapPoints;
};
export const setLastDrawTimestamp = (newTimestamp: DOMHighResTimeStamp) => {
lastDrawTimestamp = newTimestamp;
};
export const setActiveLineColor = (newColor: string, triggerReact = true) => {
activeLineColor = newColor;
if (triggerReact) {
triggerReactUpdate(StateVariable.activeLineColor);
}
};
export const setActiveLineWidth = (newWidth: number, triggerReact = true) => {
activeLineWidth = newWidth;
if (triggerReact) {
triggerReactUpdate(StateVariable.activeLineWidth);
}
};
export const setActiveLineDash = (newDash: number[] | undefined, triggerReact = true) => {
activeLineDash = newDash;
if (triggerReact) {
triggerReactUpdate(StateVariable.activeLineDash);
}
};
export const setActiveTextStyle = (
newStyle: Partial<typeof activeTextStyle>,
triggerReact = true
) => {
activeTextStyle = { ...activeTextStyle, ...newStyle };
if (triggerReact) {
triggerReactUpdate(StateVariable.activeTextStyle);
}
};
export const setLayers = (newLayers: Layer[], triggerReact = true) => {
layers = newLayers;
layersById = new Map(newLayers.map((layer) => [layer.id, layer]));
bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows
if (triggerReact) {
triggerReactUpdate(StateVariable.layers);
}
};
export const setActiveLayerId = (newActiveLayerId: string, triggerReact = true) => {
// 잠금 도면층(도각·원지반 등 배경)은 현재 도면층이 될 수 없다 — 새 객체가 배경으로
// 떨어지면 그린 즉시 집지도 지우지도 못한다. 호출부(브리지·복구·가져오기·도면층
// 패널·리본)마다 거르지 않고 여기 한 곳에서 잠금 아닌 첫 층으로 돌린다.
if (layersById.get(newActiveLayerId)?.isLocked) {
newActiveLayerId = layers.find((layer) => !layer.isLocked)?.id ?? newActiveLayerId;
}
activeLayerId = newActiveLayerId;
if (triggerReact) {
triggerReactUpdate(StateVariable.layers);
}
};
export const setSnapEnabled = (enabled: boolean) => {
snapEnabled = enabled;
if (!enabled) {
setSnapPoint(null);
setSnapPointOnAngleGuide(null);
setHoveredSnapPoints([]);
setAngleGuideEntities([]);
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
export const setSnapTrackingEnabled = (enabled: boolean) => {
snapTrackingEnabled = enabled;
if (!enabled) {
setHoveredSnapPoints([]);
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
export const setGridEnabled = (enabled: boolean) => {
gridEnabled = enabled;
bumpSceneVersion();
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
export const setDesignMeta = (newMeta: DesignMeta | null) => {
designMeta = newMeta;
triggerReactUpdate(StateVariable.designMeta);
};
// 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정).
// 값을 바꾸려면 횡단설계에서 고치고 돌아온다 — 여기 있던 setDesignQuantityTable은
// 어디서도 부르지 않으면서 "고칠 수 있는 값"으로 오해를 남겨 지웠다.
// Computed setters
export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => {
const entityIdsToBeDeleted = entitiesToDelete.map((entity) => entity.id);
const newEntities = getEntities().filter((entity) => !entityIdsToBeDeleted.includes(entity.id));
setEntities(newEntities, trackInUndoStack);
return newEntities;
};
export const addEntities = (entitiesToAdd: Entity[], trackInUndoStack: boolean): Entity[] => {
const newEntities = [...getEntities(), ...entitiesToAdd];
setEntities(newEntities, trackInUndoStack);
return newEntities;
};
// Undo redo states
const reactStateVariables: StateVariable[] = [
StateVariable.activeTool,
StateVariable.angleStep,
StateVariable.activeLineColor,
StateVariable.activeLineWidth,
StateVariable.activeLineDash,
StateVariable.activeTextStyle,
StateVariable.designMeta,
StateVariable.screenZoom,
StateVariable.layers,
];
const undoableStateVariables: StateVariable[] = [StateVariable.entities];
const undoStack = createStack();
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
function trackUndoState(variable: StateVariable, value: any) {
if (!undoableStateVariables.includes(variable)) return;
const lastUndoState = undoStack.peek();
if (isEqual(value, lastUndoState?.value)) {
return; // Sometimes entities are updated because of highlighting, but not actually differ with the last list of entities
}
// Push the new undo state
undoStack.push({ variable: variable, value: value });
}
function updateStates(undoState: UndoState) {
const variable = undoState.variable;
const value = undoState.value;
// Do not use the setters for setting these states, otherwise you trigger the undo stack again
switch (variable) {
case StateVariable.entities:
entities = value;
bumpSceneVersion();
break;
}
}
/**
* 지금 객체 목록을 되돌리기 스택의 바닥으로 삼는다. 서버 도면을 실은 직후에 부른다 —
* 안 부르면 첫 Ctrl+Z가 도면을 싣기 전의 빈 상태로 되돌아가 화면이 백지가 된다.
*/
export function resetUndoBaseline() {
undoStack.clear(StateVariable.entities);
undoStack.push({ variable: StateVariable.entities, value: entities });
drawingDirty = false;
}
export function undo() {
const undoState = undoStack.undo();
if (!undoState) return;
updateStates(undoState);
drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
}
export function redo() {
const redoState = undoStack.redo();
if (!redoState) return;
updateStates(redoState);
drawingDirty = true;
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
}
export function triggerReactUpdate(variable: StateVariable) {
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
return;
}
if (!reactStateVariables.includes(variable)) {
return;
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
}