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: let activeToolActor: Actor | 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 = 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 = 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 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 = 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; /** 도각 편집 모드인가 — 부모(B07 화면)가 도각을 실을 때 켠다. 자리표 패널이 이때만 뜬다. */ let frameEditMode = false; /** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */ let frameFields: Record = {}; /** * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — * 없으면 사용자가 그은 선이 경고 없이 사라진다(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 isFrameEditMode = (): boolean => frameEditMode; export const getFrameFields = (): Record => frameFields; 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: newToolActor: Actor, triggerReact = true ) => { const oldToolActor = getActiveToolActor(); oldToolActor?.stop(); activeToolActor = newToolActor; activeToolActor.subscribe({ // biome-ignore lint/suspicious/noExplicitAny: next: (state: MachineSnapshot) => { const stateInstructions = Object.values(state?.getMeta() as Record)[0] ?.instructions; if (getLastStateInstructions() === stateInstructions) { return; } setLastStateInstructions(stateInstructions || null); }, error: (err) => { toast.error( `Error in tool actor: ${ // biome-ignore lint/suspicious/noExplicitAny: (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: activeTool: (activeToolActor.src as any).config.context.type, }); if (triggerReact) { triggerReactUpdate(StateVariable.activeTool); } }; /** 화면에 알린다 — `window`가 없는 Node 시험에서는 통지를 건너뛴다. * (`triggerReactUpdate`가 이미 같은 이유로 시험 환경을 건너뛴다.) */ export const notifyWindow = (event: HtmlEvent) => { if (typeof window === 'undefined') return; window.dispatchEvent(new CustomEvent(event)); }; export const setLastStateInstructions = (newInstructions: string | null) => { lastStateInstructions = newInstructions; notifyWindow(HtmlEvent.UPDATE_STATE); }; export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => { if (trackInUndoStack) { trackUndoState(StateVariable.entities, newEntities); } entities = newEntities; bumpSceneVersion(); if (trackInUndoStack) { drawingDirty = true; notifyWindow(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 notifyWindow(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, 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([]); } notifyWindow(HtmlEvent.UPDATE_STATE); }; export const setSnapTrackingEnabled = (enabled: boolean) => { snapTrackingEnabled = enabled; if (!enabled) { setHoveredSnapPoints([]); } notifyWindow(HtmlEvent.UPDATE_STATE); }; export const setGridEnabled = (enabled: boolean) => { gridEnabled = enabled; bumpSceneVersion(); notifyWindow(HtmlEvent.UPDATE_STATE); }; export const setDesignMeta = (newMeta: DesignMeta | null) => { designMeta = newMeta; triggerReactUpdate(StateVariable.designMeta); }; /** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */ export const setFrameEditMode = (enabled: boolean, fields: Record = {}) => { frameEditMode = enabled; frameFields = enabled ? fields : {}; notifyWindow(HtmlEvent.UPDATE_STATE); }; // 수량표는 앞 단계(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: 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; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다 notifyWindow(HtmlEvent.DRAWING_CHANGED); } export function redo() { const redoState = undoStack.redo(); if (!redoState) return; updateStates(redoState); drawingDirty = true; notifyWindow(HtmlEvent.DRAWING_CHANGED); } export function triggerReactUpdate(variable: StateVariable) { if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') { return; } if (!reactStateVariables.includes(variable)) { return; } notifyWindow(HtmlEvent.UPDATE_STATE); }