@pascal-app/editor 1.0.1 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -9
- package/src/components/editor/action-menu-button.tsx +83 -0
- package/src/components/editor/bake-thumbnail.tsx +315 -4
- package/src/components/editor/capture-level-picker.tsx +72 -0
- package/src/components/editor/custom-camera-controls.tsx +221 -128
- package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
- package/src/components/editor/duplicate-node.ts +244 -0
- package/src/components/editor/editor-layout-mobile.tsx +37 -25
- package/src/components/editor/editor-layout-v2.tsx +131 -58
- package/src/components/editor/export-manager.tsx +34 -8
- package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
- package/src/components/editor/first-person/build-collider-world.ts +6 -2
- package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
- package/src/components/editor/first-person/mechanism-targets.ts +36 -0
- package/src/components/editor/first-person-controls.tsx +205 -2
- package/src/components/editor/floating-action-menu.tsx +63 -207
- package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
- package/src/components/editor/floor-aware-camera-controls.ts +324 -0
- package/src/components/editor/floor-openings-overlay.tsx +243 -0
- package/src/components/editor/floor-region-controls.tsx +846 -0
- package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
- package/src/components/editor/floor-surface.test.ts +165 -0
- package/src/components/editor/floor-surface.ts +182 -0
- package/src/components/editor/floorplan-background-selection.test.ts +11 -2
- package/src/components/editor/floorplan-background-selection.ts +1 -3
- package/src/components/editor/floorplan-panel.tsx +536 -487
- package/src/components/editor/footprint-height-handle.tsx +204 -0
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +59 -11
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
- package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
- package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
- package/src/components/editor/handles/use-handle-drag.ts +272 -131
- package/src/components/editor/index.tsx +316 -176
- package/src/components/editor/join-walls-pill.tsx +44 -0
- package/src/components/editor/node-action-menu.tsx +70 -70
- package/src/components/editor/node-arrow-handles.tsx +167 -14
- package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
- package/src/components/editor/open-wall-ends-hint.tsx +89 -0
- package/src/components/editor/quick-measurement-hud.tsx +3 -2
- package/src/components/editor/registry-action-contributions.test.tsx +63 -0
- package/src/components/editor/registry-action-contributions.tsx +132 -0
- package/src/components/editor/room-controls.tsx +847 -0
- package/src/components/editor/room-floor-highlight.tsx +128 -0
- package/src/components/editor/room-handles-2d.tsx +354 -0
- package/src/components/editor/room-handles.tsx +675 -0
- package/src/components/editor/room-highlight.tsx +225 -0
- package/src/components/editor/room-labels.test.ts +357 -0
- package/src/components/editor/room-labels.tsx +990 -0
- package/src/components/editor/room-transform-ghost.tsx +256 -0
- package/src/components/editor/room-webgpu-materials.test.ts +27 -0
- package/src/components/editor/selection-manager.tsx +451 -317
- package/src/components/editor/slab-hole-highlights.tsx +3 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
- package/src/components/editor/snapshot-capture.test.ts +52 -4
- package/src/components/editor/snapshot-capture.ts +30 -5
- package/src/components/editor/thumbnail-generator.test.ts +308 -0
- package/src/components/editor/thumbnail-generator.tsx +1035 -229
- package/src/components/editor/use-floorplan-background-placement.ts +31 -11
- package/src/components/editor/vector-edge-extractor.tsx +121 -0
- package/src/components/editor/views/host-view.tsx +61 -0
- package/src/components/editor/views/plugins-view.tsx +670 -0
- package/src/components/editor/views/scene-region.tsx +59 -0
- package/src/components/editor/views/use-editor-views.ts +57 -0
- package/src/components/editor/views/view-bar.tsx +220 -0
- package/src/components/editor/wall-justification.test.ts +140 -0
- package/src/components/editor/wall-move-side-handles.tsx +114 -163
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor/wall-push-arrow.tsx +92 -0
- package/src/components/editor/wall-region-handles.tsx +509 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
- package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
- package/src/components/editor-2d/svg-paths.ts +12 -15
- package/src/components/procedural-items/inspector.tsx +40 -15
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
- package/src/components/systems/zone/zone-system.test.ts +47 -0
- package/src/components/systems/zone/zone-system.tsx +31 -66
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-history.test.tsx +474 -0
- package/src/components/tools/item/placement-strategies.ts +63 -47
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.test.tsx +432 -1
- package/src/components/tools/item/use-draft-node.ts +207 -97
- package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
- package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
- package/src/components/tools/select/box-select-tool.tsx +2 -12
- package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
- package/src/components/tools/select/select-candidates.test.ts +37 -4
- package/src/components/tools/select/select-candidates.ts +15 -11
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/cursor-sphere.tsx +36 -12
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/site/site-boundary-editor.tsx +1 -2
- package/src/components/tools/stair/stair-defaults.ts +0 -3
- package/src/components/tools/stair/stair-tool.tsx +90 -42
- package/src/components/tools/tool-manager.tsx +82 -23
- package/src/components/tools/wall/wall-drafting.test.ts +19 -4
- package/src/components/tools/zone/zone-tool.tsx +4 -12
- package/src/components/ui/action-menu/action-button.tsx +3 -0
- package/src/components/ui/action-menu/camera-actions.tsx +2 -1
- package/src/components/ui/action-menu/control-modes.tsx +2 -35
- package/src/components/ui/action-menu/measurement-control.tsx +3 -10
- package/src/components/ui/build-panel/build-panel.tsx +235 -0
- package/src/components/ui/command-palette/editor-commands.tsx +4 -30
- package/src/components/ui/command-palette/index.tsx +5 -24
- package/src/components/ui/controls/finish-swatch.tsx +110 -0
- package/src/components/ui/controls/material-paint-panel.tsx +101 -91
- package/src/components/ui/controls/material-picker.tsx +8 -67
- package/src/components/ui/controls/scene-material-list.tsx +4 -32
- package/src/components/ui/controls/select-control.tsx +65 -0
- package/src/components/ui/controls/slider-control.tsx +76 -31
- package/src/components/ui/floating-level-selector.tsx +9 -14
- package/src/components/ui/helpers/building-helper.tsx +4 -1
- package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
- package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
- package/src/components/ui/helpers/helper-manager.tsx +348 -19
- package/src/components/ui/helpers/item-helper.tsx +8 -0
- package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
- package/src/components/ui/icon-ref.tsx +45 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/node-icon-image.tsx +16 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
- package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
- package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
- package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
- package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
- package/src/components/ui/panels/multi-field-value.test.ts +26 -3
- package/src/components/ui/panels/multi-field-value.ts +6 -3
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
- package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
- package/src/components/ui/panels/node-display.ts +8 -3
- package/src/components/ui/panels/panel-manager.tsx +103 -68
- package/src/components/ui/panels/panel-wrapper.tsx +78 -36
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +7 -19
- package/src/components/ui/panels/reference-panel.tsx +2 -3
- package/src/components/ui/panels/room-construction-rows.tsx +774 -0
- package/src/components/ui/panels/room-name-options.test.ts +55 -0
- package/src/components/ui/panels/room-name-options.ts +47 -0
- package/src/components/ui/panels/room-openings-field.tsx +110 -0
- package/src/components/ui/panels/room-panel.tsx +725 -0
- package/src/components/ui/panels/shape-choice.tsx +58 -0
- package/src/components/ui/panels/wall-region-list.tsx +109 -0
- package/src/components/ui/primitives/color-dot.tsx +36 -24
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/primitives/sidebar.tsx +2 -0
- package/src/components/ui/right-stack.test.ts +29 -0
- package/src/components/ui/right-stack.tsx +145 -0
- package/src/components/ui/sidebar/icon-rail.tsx +5 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
- package/src/components/ui/sidebar/tab-bar.tsx +10 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
- package/src/components/viewer/floorplan-compass-button.tsx +9 -1
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +6 -18
- package/src/components/viewer/viewer-hover-label.tsx +34 -0
- package/src/components/viewer/viewer-rooms.tsx +175 -0
- package/src/components/viewer/viewer-scene-header.tsx +181 -145
- package/src/components/viewer/viewer-selection-manager.tsx +124 -0
- package/src/components/viewer/viewer-stage.tsx +2 -2
- package/src/components/viewer-zone-system.tsx +9 -9
- package/src/hooks/use-auto-save.integration.test.tsx +311 -0
- package/src/hooks/use-auto-save.ts +55 -14
- package/src/hooks/use-ceiling-events.test.ts +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +182 -0
- package/src/hooks/use-keyboard.ts +122 -91
- package/src/hooks/use-open-wall-ends.test.ts +99 -0
- package/src/hooks/use-open-wall-ends.ts +238 -0
- package/src/hooks/use-placement-notice.test.ts +48 -0
- package/src/hooks/use-placement-notice.ts +42 -0
- package/src/hooks/use-selected-room.ts +143 -0
- package/src/index.tsx +306 -39
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/ceiling-edit-session.test.ts +321 -0
- package/src/lib/ceiling-edit-session.ts +206 -0
- package/src/lib/ceiling-region-session.test.ts +179 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/continuation.test.ts +19 -1
- package/src/lib/continuation.ts +25 -4
- package/src/lib/door-interaction.ts +14 -0
- package/src/lib/editor-level-display.test.ts +96 -0
- package/src/lib/editor-level-display.ts +48 -0
- package/src/lib/editor-views.ts +144 -0
- package/src/lib/export-geometry-wait.test.ts +90 -0
- package/src/lib/floor-edit-session.ts +153 -0
- package/src/lib/floor-footprints.test.ts +563 -0
- package/src/lib/floor-footprints.ts +395 -0
- package/src/lib/floor-foundation-support.test.ts +283 -0
- package/src/lib/floor-opening-draft.ts +218 -0
- package/src/lib/floor-openings.test.ts +216 -0
- package/src/lib/floor-region-geometry.ts +191 -0
- package/src/lib/floor-region-session.test.ts +485 -0
- package/src/lib/floor-region-session.ts +589 -0
- package/src/lib/floor-region-snap.ts +101 -0
- package/src/lib/floor-step-hover.test.ts +32 -0
- package/src/lib/floorplan/annotation-visibility.ts +17 -0
- package/src/lib/floorplan/dimension-drive.test.ts +360 -0
- package/src/lib/floorplan/dimension-drive.ts +479 -0
- package/src/lib/floorplan/floorplan-export.test.ts +15 -9
- package/src/lib/floorplan/floorplan-export.tsx +32 -8
- package/src/lib/floorplan/floorplan-extension.ts +48 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
- package/src/lib/floorplan/floorplan-mode.ts +2 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
- package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
- package/src/lib/floorplan/floorplan-readonly.ts +6 -0
- package/src/lib/floorplan/geometry.ts +13 -6
- package/src/lib/floorplan/index.ts +0 -9
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/marks.test.ts +213 -0
- package/src/lib/floorplan/marks.ts +238 -0
- package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
- package/src/lib/floorplan/open-wall-ends.ts +162 -0
- package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
- package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
- package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
- package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
- package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
- package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
- package/src/lib/floorplan/schedules.test.ts +189 -0
- package/src/lib/floorplan/schedules.ts +339 -0
- package/src/lib/floorplan/sheet-export.test.ts +100 -0
- package/src/lib/floorplan/sheet-export.ts +318 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
- package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
- package/src/lib/floorplan/site-plan/contributors.ts +97 -0
- package/src/lib/floorplan/site-plan/coverage.ts +232 -0
- package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
- package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
- package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
- package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
- package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
- package/src/lib/floorplan/site-plan/geometry.ts +557 -0
- package/src/lib/floorplan/site-plan/index.ts +71 -0
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
- package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
- package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
- package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
- package/src/lib/floorplan/site-plan/street-name.ts +302 -0
- package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
- package/src/lib/floorplan/types.ts +1 -27
- package/src/lib/fresh-planar-placement.test.ts +36 -0
- package/src/lib/fresh-planar-placement.ts +191 -36
- package/src/lib/geometry-script/author.ts +152 -0
- package/src/lib/geometry-script/client.ts +152 -0
- package/src/lib/geometry-script/protocol.ts +12 -0
- package/src/lib/gesture-lifecycle.test.ts +595 -0
- package/src/lib/gesture-lifecycle.ts +290 -0
- package/src/lib/glb-export-zone-holes.test.ts +41 -0
- package/src/lib/glb-export.test.ts +544 -16
- package/src/lib/glb-export.ts +256 -125
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/history.test.ts +6 -0
- package/src/lib/history.ts +7 -0
- package/src/lib/hud-title.ts +109 -0
- package/src/lib/hydration-gated-storage.ts +49 -0
- package/src/lib/ifc-export.test.ts +174 -0
- package/src/lib/ifc-export.ts +201 -0
- package/src/lib/ifc-parts.ts +15 -0
- package/src/lib/inspector-expanded.test.ts +44 -0
- package/src/lib/inspector-expanded.ts +36 -0
- package/src/lib/interaction/scope.ts +27 -1
- package/src/lib/keyboard-pan.test.ts +184 -0
- package/src/lib/keyboard-pan.ts +238 -0
- package/src/lib/live-gesture-round-trip.test.ts +360 -0
- package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
- package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
- package/src/lib/lot/clean-ring.test.ts +186 -0
- package/src/lib/lot/clean-ring.ts +217 -0
- package/src/lib/lot/dossier.test.ts +202 -0
- package/src/lib/lot/dossier.ts +498 -0
- package/src/lib/lot/drop-in.test.ts +326 -0
- package/src/lib/lot/drop-in.ts +407 -0
- package/src/lib/lot/index.ts +43 -0
- package/src/lib/lot/lot-patch.test.ts +340 -0
- package/src/lib/lot/lot-patch.ts +394 -0
- package/src/lib/lot/parcel-provider.ts +43 -0
- package/src/lib/lot/terrain.test.ts +161 -0
- package/src/lib/lot/terrain.ts +266 -0
- package/src/lib/material-catalog-model.ts +74 -0
- package/src/lib/material-paint-panel-model.ts +80 -0
- package/src/lib/material-paint.ts +4 -0
- package/src/lib/mezzanine-actions.test.ts +342 -0
- package/src/lib/mezzanine-draft.test.ts +501 -0
- package/src/lib/mezzanine-draft.ts +278 -0
- package/src/lib/mezzanine-messages.ts +26 -0
- package/src/lib/mezzanine-selection.test.ts +207 -0
- package/src/lib/mezzanine-stairs.ts +46 -0
- package/src/lib/model-export.ts +3 -1
- package/src/lib/node-action-movement.ts +17 -6
- package/src/lib/node-display-label.ts +37 -0
- package/src/lib/orbit-floor-clearance.test.ts +165 -0
- package/src/lib/orbit-floor-clearance.ts +106 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/paint-commit-route.test.ts +37 -0
- package/src/lib/paint-commit-route.ts +17 -0
- package/src/lib/paint-eyedropper.test.tsx +165 -0
- package/src/lib/paint-eyedropper.ts +99 -0
- package/src/lib/paint-pass-through.test.ts +111 -0
- package/src/lib/paint-pass-through.ts +76 -0
- package/src/lib/paint-region-hover.ts +25 -0
- package/src/lib/paint-region-mode.ts +154 -0
- package/src/lib/paint-regions.test.ts +166 -0
- package/src/lib/paint-regions.ts +192 -0
- package/src/lib/paint-scope.test.ts +317 -35
- package/src/lib/paint-scope.ts +266 -25
- package/src/lib/panel-rows.ts +96 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/plate-paint-affected.test.ts +300 -0
- package/src/lib/plate-paint-affected.ts +177 -0
- package/src/lib/plugin-chat.ts +50 -0
- package/src/lib/plugin-panels.test.ts +25 -0
- package/src/lib/plugin-panels.ts +94 -0
- package/src/lib/print-shell-compiler.ts +1 -0
- package/src/lib/registered-draft-snap.ts +12 -0
- package/src/lib/roof-duplication.ts +10 -2
- package/src/lib/room-assembly-overlay.test.ts +275 -0
- package/src/lib/room-assembly-overlay.ts +266 -0
- package/src/lib/room-built-on.test.ts +379 -0
- package/src/lib/room-built-on.ts +138 -0
- package/src/lib/room-construction-commands.ts +712 -0
- package/src/lib/room-construction.test.ts +746 -0
- package/src/lib/room-construction.ts +385 -0
- package/src/lib/room-controls-phase6.test.ts +630 -0
- package/src/lib/room-divide-session.ts +523 -0
- package/src/lib/room-handle-drag.ts +1068 -0
- package/src/lib/room-name-catalog.test.ts +90 -0
- package/src/lib/room-name-catalog.ts +168 -0
- package/src/lib/room-push-dimensions.test.ts +311 -0
- package/src/lib/room-push-dimensions.ts +251 -0
- package/src/lib/room-selection-commands.ts +62 -0
- package/src/lib/room-selection.test.ts +973 -0
- package/src/lib/room-selection.ts +417 -0
- package/src/lib/room-structure-commands.ts +94 -0
- package/src/lib/room-structure.test.ts +921 -0
- package/src/lib/room-transform-session.ts +570 -0
- package/src/lib/room-zone-routing.test.ts +294 -0
- package/src/lib/room-zone-routing.ts +197 -0
- package/src/lib/scene-clipboard.test.ts +364 -0
- package/src/lib/scene-clipboard.ts +229 -48
- package/src/lib/scene-material-refs.test.ts +79 -0
- package/src/lib/scene-material-refs.ts +79 -0
- package/src/lib/scene.test.ts +87 -0
- package/src/lib/scene.ts +39 -45
- package/src/lib/selection-routing.test.ts +129 -45
- package/src/lib/selection-routing.ts +53 -74
- package/src/lib/session-writes.test.ts +120 -0
- package/src/lib/session-writes.ts +205 -0
- package/src/lib/sidebar-panel.ts +62 -0
- package/src/lib/site-boundary.test.ts +4 -6
- package/src/lib/site-boundary.ts +1 -1
- package/src/lib/snapping-mode.test.ts +17 -2
- package/src/lib/snapping-mode.ts +19 -7
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/terrace-draft.test.ts +145 -0
- package/src/lib/terrace-draft.ts +213 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/tool-cursor-icon.test.ts +16 -0
- package/src/lib/tool-cursor-icon.ts +11 -0
- package/src/lib/typing-target.ts +9 -0
- package/src/lib/units.ts +77 -39
- package/src/lib/vector-edges.test.ts +58 -0
- package/src/lib/vector-edges.ts +400 -0
- package/src/lib/viewer-selection.test.ts +577 -0
- package/src/lib/viewer-selection.ts +569 -0
- package/src/lib/wall-draw-variant.test.ts +71 -0
- package/src/lib/wall-draw-variant.ts +91 -0
- package/src/lib/wall-paint-region-session.ts +246 -0
- package/src/lib/wall-paint-region.test.ts +373 -0
- package/src/lib/wall-polygon-draft.test.ts +262 -0
- package/src/lib/wall-polygon-draft.ts +286 -0
- package/src/lib/wall-push.test.ts +453 -0
- package/src/lib/wall-region-face.ts +352 -0
- package/src/lib/wall-region-handles.test.ts +304 -0
- package/src/lib/wall-region-handles.ts +368 -0
- package/src/lib/wall-region-list.test.ts +119 -0
- package/src/lib/wall-region-list.ts +136 -0
- package/src/lib/wall-region-snap.ts +110 -0
- package/src/lib/window-interaction.ts +14 -0
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/lib/zone-content.ts +4 -1
- package/src/lib/zone-name.test.ts +17 -0
- package/src/lib/zone-name.ts +17 -0
- package/src/store/opens-in-select-mode.test.ts +223 -0
- package/src/store/paint-mode.test.ts +188 -0
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/terrain-sculpt-mode.test.ts +19 -21
- package/src/store/tool-mode.test.ts +137 -6
- package/src/store/use-delete-confirmation.ts +28 -1
- package/src/store/use-drawing-view.test.ts +24 -3
- package/src/store/use-drawing-view.ts +29 -1
- package/src/store/use-editor.tsx +442 -323
- package/src/store/use-floorplan-draft-preview.ts +23 -1
- package/src/store/use-floorplan-marquee.ts +12 -2
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-quick-measurement-hud.ts +3 -3
- package/src/store/use-stair-build-preview.ts +13 -5
- package/src/store/use-wall-move-ghosts.ts +4 -2
- package/src/store/use-wall-region-handles-selection.test.ts +222 -0
- package/src/store/use-wall-region-handles-selection.ts +139 -0
- package/src/store/view-layout.test.ts +175 -0
- package/src/store/view-layout.ts +196 -0
- package/src/test-utils/immediate-animation-frames.ts +16 -0
- package/src/test-utils/live-gesture-round-trip.ts +224 -0
- package/src/test-utils/selection-harness.ts +82 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
- package/src/components/tools/fence/fence-drafting.test.ts +0 -100
- package/src/components/tools/fence/fence-drafting.ts +0 -266
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
- package/src/components/viewer/viewer-units-panel.tsx +0 -116
- package/src/lib/floorplan/stairs.ts +0 -489
- package/src/lib/level-duplication.test.ts +0 -144
- package/src/lib/level-duplication.ts +0 -195
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, emitter, type NodeEvent, sceneRegistry, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { useThree } from '@react-three/fiber'
|
|
6
|
+
import { useEffect } from 'react'
|
|
7
|
+
import { selectionModifiersFromEvent } from '../../lib/selection-routing'
|
|
8
|
+
import {
|
|
9
|
+
applyViewerClick,
|
|
10
|
+
applyViewerHover,
|
|
11
|
+
viewerPickFromNodeEvent,
|
|
12
|
+
} from '../../lib/viewer-selection'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Selection for a parametric viewer surface (the published viewer's fallback,
|
|
16
|
+
* its embed, the editor's Preview): free-form and room-first, through the same
|
|
17
|
+
* rules as the baked viewer. Mount it with `<Viewer selectionManager="custom">`.
|
|
18
|
+
*/
|
|
19
|
+
export function ViewerSelectionManager() {
|
|
20
|
+
const gl = useThree((state) => state.gl)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let clickHandled = false
|
|
24
|
+
let hoveredFrom: string | null = null
|
|
25
|
+
const pendingClicks = new Set<number>()
|
|
26
|
+
|
|
27
|
+
const onMove = (event: NodeEvent) => {
|
|
28
|
+
if (useViewer.getState().walkthroughMode) return
|
|
29
|
+
const pick = viewerPickFromNodeEvent(event, useScene.getState().nodes)
|
|
30
|
+
if (!pick) return
|
|
31
|
+
event.stopPropagation()
|
|
32
|
+
hoveredFrom = event.node.id
|
|
33
|
+
applyViewerHover(pick, useScene.getState().nodes)
|
|
34
|
+
}
|
|
35
|
+
const onLeave = (event: NodeEvent) => {
|
|
36
|
+
if (event.node.id !== hoveredFrom) return
|
|
37
|
+
hoveredFrom = null
|
|
38
|
+
useViewer.getState().setHoveredId(null)
|
|
39
|
+
}
|
|
40
|
+
const onClick = (event: NodeEvent) => {
|
|
41
|
+
if (useViewer.getState().walkthroughMode) return
|
|
42
|
+
const pick = viewerPickFromNodeEvent(event, useScene.getState().nodes)
|
|
43
|
+
if (!pick) return
|
|
44
|
+
event.stopPropagation()
|
|
45
|
+
clickHandled = true
|
|
46
|
+
applyViewerClick(
|
|
47
|
+
pick,
|
|
48
|
+
useScene.getState().nodes,
|
|
49
|
+
selectionModifiersFromEvent(event.nativeEvent),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
// A click on nothing selectable clears the room and the element.
|
|
53
|
+
const onCanvasClick = (event: MouseEvent) => {
|
|
54
|
+
const viewer = useViewer.getState()
|
|
55
|
+
if (event.button !== 0 || viewer.walkthroughMode) return
|
|
56
|
+
if (viewer.cameraDragging || viewer.inputDragging) return
|
|
57
|
+
const frame = requestAnimationFrame(() => {
|
|
58
|
+
pendingClicks.delete(frame)
|
|
59
|
+
if (useViewer.getState().walkthroughMode) return
|
|
60
|
+
if (clickHandled) {
|
|
61
|
+
clickHandled = false
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
applyViewerClick(null, useScene.getState().nodes)
|
|
65
|
+
})
|
|
66
|
+
pendingClicks.add(frame)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
emitter.on('node:move', onMove)
|
|
70
|
+
emitter.on('node:enter', onMove)
|
|
71
|
+
emitter.on('node:leave', onLeave)
|
|
72
|
+
emitter.on('node:click', onClick)
|
|
73
|
+
const canvas = gl.domElement
|
|
74
|
+
canvas.addEventListener('click', onCanvasClick)
|
|
75
|
+
return () => {
|
|
76
|
+
emitter.off('node:move', onMove)
|
|
77
|
+
emitter.off('node:enter', onMove)
|
|
78
|
+
emitter.off('node:leave', onLeave)
|
|
79
|
+
emitter.off('node:click', onClick)
|
|
80
|
+
canvas.removeEventListener('click', onCanvasClick)
|
|
81
|
+
for (const frame of pendingClicks) cancelAnimationFrame(frame)
|
|
82
|
+
useViewer.getState().setHoveredId(null)
|
|
83
|
+
}
|
|
84
|
+
}, [gl])
|
|
85
|
+
|
|
86
|
+
return <ViewerOutlineSync />
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The selected elements and the hovered one, outlined (rooms highlight instead). */
|
|
90
|
+
function ViewerOutlineSync() {
|
|
91
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
92
|
+
const externalSelectedIds = useViewer((s) => s.externalSelectedIds)
|
|
93
|
+
const hoveredId = useViewer((s) => s.hoveredId)
|
|
94
|
+
const outliner = useViewer((s) => s.outliner)
|
|
95
|
+
const geometryRevision = useViewer((s) => s.geometryRevision)
|
|
96
|
+
const walkthrough = useViewer((s) => s.walkthroughMode)
|
|
97
|
+
const nodes = useScene((s) => s.nodes)
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
void geometryRevision
|
|
101
|
+
outliner.selectedObjects.length = 0
|
|
102
|
+
outliner.hoveredObjects.length = 0
|
|
103
|
+
if (walkthrough) return
|
|
104
|
+
for (const id of new Set([...selectedIds, ...externalSelectedIds])) {
|
|
105
|
+
const object = sceneRegistry.nodes.get(id)
|
|
106
|
+
if (object) outliner.selectedObjects.push(object)
|
|
107
|
+
}
|
|
108
|
+
const hovered = hoveredId ? nodes[hoveredId as AnyNodeId] : undefined
|
|
109
|
+
if (hovered && hovered.type !== 'zone') {
|
|
110
|
+
const object = sceneRegistry.nodes.get(hovered.id)
|
|
111
|
+
if (object) outliner.hoveredObjects.push(object)
|
|
112
|
+
}
|
|
113
|
+
}, [selectedIds, externalSelectedIds, hoveredId, outliner, nodes, geometryRevision, walkthrough])
|
|
114
|
+
|
|
115
|
+
useEffect(
|
|
116
|
+
() => () => {
|
|
117
|
+
outliner.selectedObjects.length = 0
|
|
118
|
+
outliner.hoveredObjects.length = 0
|
|
119
|
+
},
|
|
120
|
+
[outliner],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
@@ -60,7 +60,6 @@ function selectViewerLevel(nodes: Record<string, AnyNode>, levelId: string) {
|
|
|
60
60
|
selectedIds: [],
|
|
61
61
|
zoneId: null,
|
|
62
62
|
})
|
|
63
|
-
viewer.setLevelMode('solo')
|
|
64
63
|
}
|
|
65
64
|
|
|
66
65
|
export function ViewerStage({
|
|
@@ -137,7 +136,8 @@ export function ViewerStage({
|
|
|
137
136
|
if (notify && nextLevelId !== useViewer.getState().selection.levelId) {
|
|
138
137
|
markPerfAction('level-switch', nextLevelId)
|
|
139
138
|
}
|
|
140
|
-
|
|
139
|
+
if (nextLevelId !== useViewer.getState().selection.levelId)
|
|
140
|
+
selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId)
|
|
141
141
|
if (notify) onLevelChange?.(nextLevelId)
|
|
142
142
|
},
|
|
143
143
|
[onLevelChange, scene],
|
|
@@ -11,7 +11,6 @@ import useInteractionScope from '../store/use-interaction-scope'
|
|
|
11
11
|
export const ViewerZoneSystem = () => {
|
|
12
12
|
useFrame(() => {
|
|
13
13
|
const { levelId, zoneId } = useViewer.getState().selection
|
|
14
|
-
const structureLayer = useEditor.getState().structureLayer
|
|
15
14
|
const nodes = useScene.getState().nodes
|
|
16
15
|
// Snapshot capture is a clean, camera-only surface — zone geometry and
|
|
17
16
|
// tags stay out of the framed shot (mirrors the editor ZoneSystem's gate).
|
|
@@ -29,14 +28,14 @@ export const ViewerZoneSystem = () => {
|
|
|
29
28
|
|
|
30
29
|
const isOnSelectedLevel = zone.parentId === levelId
|
|
31
30
|
|
|
32
|
-
// Keep group visible (so <Html> labels stay active), hide/show meshes only
|
|
33
|
-
//
|
|
34
|
-
// The editor ZoneSystem handles the selected zone's opacity animation.
|
|
31
|
+
// Keep group visible (so <Html> labels stay active), hide/show meshes only:
|
|
32
|
+
// only the selected zone shows its volume.
|
|
35
33
|
const isSelected = id === zoneId
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
// A zone the author hid (sidebar eye) takes the group with it — this
|
|
35
|
+
// per-frame write would otherwise undo the renderer's `visible` prop.
|
|
36
|
+
const nodeVisible = zone.visible !== false
|
|
37
|
+
const shouldShowGeometry = nodeVisible && !isCaptureMode && isSelected
|
|
38
|
+
if (obj.visible !== nodeVisible) obj.visible = nodeVisible
|
|
40
39
|
obj.traverse((child) => {
|
|
41
40
|
if ((child as Mesh).isMesh) {
|
|
42
41
|
child.visible = shouldShowGeometry
|
|
@@ -44,7 +43,8 @@ export const ViewerZoneSystem = () => {
|
|
|
44
43
|
})
|
|
45
44
|
|
|
46
45
|
// Labels: always visible on the current level (regardless of mode or zone selection)
|
|
47
|
-
const showLabel =
|
|
46
|
+
const showLabel =
|
|
47
|
+
nodeVisible && !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
|
|
48
48
|
const targetOpacity = showLabel ? '1' : '0'
|
|
49
49
|
const labelEl = document.getElementById(`${id}-label`)
|
|
50
50
|
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
BuildingNode,
|
|
4
|
+
ColumnNode,
|
|
5
|
+
clearSceneHistory,
|
|
6
|
+
LevelNode,
|
|
7
|
+
SiteNode,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { create } from '@react-three/test-renderer'
|
|
11
|
+
import { type SaveStatus, useAutoSave } from './use-auto-save'
|
|
12
|
+
|
|
13
|
+
const originalWindow = globalThis.window
|
|
14
|
+
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
|
|
15
|
+
const previousScene = useScene.getState()
|
|
16
|
+
let renderer: Awaited<ReturnType<typeof create>> | undefined
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await renderer?.unmount()
|
|
20
|
+
renderer = undefined
|
|
21
|
+
globalThis.window = originalWindow
|
|
22
|
+
globalThis.requestAnimationFrame = originalRequestAnimationFrame
|
|
23
|
+
useScene.setState(previousScene, true)
|
|
24
|
+
clearSceneHistory()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function graph(count: number) {
|
|
28
|
+
if (count === 0) return { nodes: {}, rootNodeIds: [] }
|
|
29
|
+
const columns = Array.from({ length: count - 3 }, (_, index) =>
|
|
30
|
+
ColumnNode.parse({ id: `column_autosave_${index}`, parentId: 'level_autosave' }),
|
|
31
|
+
)
|
|
32
|
+
const level = LevelNode.parse({
|
|
33
|
+
id: 'level_autosave',
|
|
34
|
+
parentId: 'building_autosave',
|
|
35
|
+
children: columns.map((node) => node.id),
|
|
36
|
+
})
|
|
37
|
+
const building = BuildingNode.parse({
|
|
38
|
+
id: 'building_autosave',
|
|
39
|
+
parentId: 'site_autosave',
|
|
40
|
+
children: [level.id],
|
|
41
|
+
})
|
|
42
|
+
const site = SiteNode.parse({ id: 'site_autosave', children: [building.id] })
|
|
43
|
+
const nodes = [site, building, level, ...columns]
|
|
44
|
+
return { nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), rootNodeIds: [site.id] }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Mounts the hook in its initial state: a scene load not yet completed. */
|
|
48
|
+
async function mountBeforeLoad(guardAgainstSceneWipe?: boolean, save?: () => Promise<void>) {
|
|
49
|
+
globalThis.window = new EventTarget() as unknown as Window & typeof globalThis
|
|
50
|
+
useScene.setState({ ...graph(0), readOnly: false })
|
|
51
|
+
clearSceneHistory()
|
|
52
|
+
const writes: number[] = []
|
|
53
|
+
const statuses: SaveStatus[] = []
|
|
54
|
+
let controls: ReturnType<typeof useAutoSave> | undefined
|
|
55
|
+
function Host(props: { guardAgainstSceneWipe?: boolean; isVersionPreviewMode?: boolean }) {
|
|
56
|
+
controls = useAutoSave({
|
|
57
|
+
...props,
|
|
58
|
+
onSave: async (scene) => {
|
|
59
|
+
writes.push(Object.keys(scene.nodes).length)
|
|
60
|
+
await save?.()
|
|
61
|
+
},
|
|
62
|
+
onSaveStatusChange: (status) => statuses.push(status),
|
|
63
|
+
})
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
renderer = await create(<Host guardAgainstSceneWipe={guardAgainstSceneWipe} />)
|
|
67
|
+
return {
|
|
68
|
+
writes,
|
|
69
|
+
statuses,
|
|
70
|
+
controls: controls!,
|
|
71
|
+
update: (props: { guardAgainstSceneWipe?: boolean; isVersionPreviewMode?: boolean }) =>
|
|
72
|
+
renderer!.update(<Host {...props} />),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function mount(
|
|
77
|
+
loadedCount: number,
|
|
78
|
+
guardAgainstSceneWipe?: boolean,
|
|
79
|
+
save?: () => Promise<void>,
|
|
80
|
+
) {
|
|
81
|
+
const mounted = await mountBeforeLoad(guardAgainstSceneWipe, save)
|
|
82
|
+
useScene.setState(graph(loadedCount))
|
|
83
|
+
clearSceneHistory()
|
|
84
|
+
mounted.controls.completeSceneLoad()
|
|
85
|
+
mounted.statuses.length = 0
|
|
86
|
+
return mounted
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The Editor's load sequence: begin, unload, hydrate the store, complete. */
|
|
90
|
+
function loadScene(controls: ReturnType<typeof useAutoSave>, count: number) {
|
|
91
|
+
controls.beginSceneLoad()
|
|
92
|
+
useScene.getState().unloadScene()
|
|
93
|
+
hydrate(count)
|
|
94
|
+
controls.completeSceneLoad()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hydrate(count: number) {
|
|
98
|
+
const { nodes, rootNodeIds } = graph(count)
|
|
99
|
+
useScene.getState().setScene(nodes as never, rootNodeIds as never)
|
|
100
|
+
clearSceneHistory()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function agentAddsColumn() {
|
|
104
|
+
useScene
|
|
105
|
+
.getState()
|
|
106
|
+
.createNode(
|
|
107
|
+
ColumnNode.parse({ id: 'column_agent', parentId: 'level_autosave' }),
|
|
108
|
+
'level_autosave' as never,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A hidden tab: requestAnimationFrame callbacks never run. */
|
|
113
|
+
function hideDocument() {
|
|
114
|
+
const frames: FrameRequestCallback[] = []
|
|
115
|
+
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
|
116
|
+
frames.push(callback)
|
|
117
|
+
return frames.length
|
|
118
|
+
}) as typeof requestAnimationFrame
|
|
119
|
+
return frames
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
test('a scene loaded in a hidden tab autosaves agent edits without waiting for a frame', async () => {
|
|
123
|
+
hideDocument()
|
|
124
|
+
const { writes, statuses, controls } = await mountBeforeLoad()
|
|
125
|
+
statuses.length = 0
|
|
126
|
+
// Pre-hydration document writes (the host-panel installedPlugins sync on
|
|
127
|
+
// mount) still must not arm a save of the empty store.
|
|
128
|
+
useScene.getState().setInstalledPlugins(['pre-hydration-default'], { explicit: false })
|
|
129
|
+
loadScene(controls, 12)
|
|
130
|
+
expect(statuses).toEqual([])
|
|
131
|
+
|
|
132
|
+
agentAddsColumn()
|
|
133
|
+
await Bun.sleep(1100)
|
|
134
|
+
expect(writes).toEqual([13])
|
|
135
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('opening a scene and leaving it unchanged saves nothing', async () => {
|
|
139
|
+
const { writes, statuses, controls } = await mountBeforeLoad()
|
|
140
|
+
loadScene(controls, 12)
|
|
141
|
+
statuses.length = 0
|
|
142
|
+
// Mount-time writes after the load: systems re-marking nodes, and a plugin
|
|
143
|
+
// that registers late syncing the default plugin set.
|
|
144
|
+
useScene.getState().markDirty('level_autosave' as never)
|
|
145
|
+
useScene.getState().setInstalledPlugins(['late-default'], { explicit: false })
|
|
146
|
+
await Bun.sleep(1100)
|
|
147
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
148
|
+
expect(writes).toEqual([])
|
|
149
|
+
expect(statuses).toEqual([])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('installing a plugin by hand after the load is saved', async () => {
|
|
153
|
+
const { writes, controls } = await mountBeforeLoad()
|
|
154
|
+
loadScene(controls, 12)
|
|
155
|
+
useScene.getState().setInstalledPlugins(['chosen'], { explicit: true })
|
|
156
|
+
await Bun.sleep(1100)
|
|
157
|
+
expect(writes).toEqual([12])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('the exit flush writes an agent edit made right after a hidden load', async () => {
|
|
161
|
+
hideDocument()
|
|
162
|
+
const { writes, statuses, controls } = await mountBeforeLoad()
|
|
163
|
+
loadScene(controls, 12)
|
|
164
|
+
agentAddsColumn()
|
|
165
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
166
|
+
expect(writes).toEqual([13])
|
|
167
|
+
expect(statuses).not.toContain('error')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('the exit flush still skips while a load is in flight', async () => {
|
|
171
|
+
const { writes, controls } = await mount(12)
|
|
172
|
+
agentAddsColumn()
|
|
173
|
+
controls.beginSceneLoad()
|
|
174
|
+
useScene.getState().unloadScene()
|
|
175
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
176
|
+
expect(writes).toEqual([])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('a save that came due mid-load is written once the load completes', async () => {
|
|
180
|
+
const { writes, statuses, controls } = await mount(12)
|
|
181
|
+
agentAddsColumn()
|
|
182
|
+
controls.beginSceneLoad()
|
|
183
|
+
useScene.getState().unloadScene()
|
|
184
|
+
await Bun.sleep(1100)
|
|
185
|
+
expect(writes).toEqual([])
|
|
186
|
+
expect(statuses.at(-1)).toBe('paused')
|
|
187
|
+
|
|
188
|
+
hydrate(12)
|
|
189
|
+
controls.completeSceneLoad()
|
|
190
|
+
await Bun.sleep(0)
|
|
191
|
+
expect(writes).toEqual([12])
|
|
192
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test.each([
|
|
196
|
+
'undo',
|
|
197
|
+
'redo',
|
|
198
|
+
'delete',
|
|
199
|
+
] as const)('autosave permits a 5 to 4 single-node %s', async (action) => {
|
|
200
|
+
const { writes, statuses } = await mount(5)
|
|
201
|
+
if (action === 'undo') {
|
|
202
|
+
useScene.setState(graph(4))
|
|
203
|
+
clearSceneHistory()
|
|
204
|
+
useScene.setState(graph(5))
|
|
205
|
+
useScene.temporal.getState().undo()
|
|
206
|
+
} else if (action === 'redo') {
|
|
207
|
+
useScene.getState().deleteNode('column_autosave_1')
|
|
208
|
+
useScene.temporal.getState().undo()
|
|
209
|
+
useScene.temporal.getState().redo()
|
|
210
|
+
} else {
|
|
211
|
+
useScene.getState().deleteNode('column_autosave_1')
|
|
212
|
+
}
|
|
213
|
+
expect(Object.keys(useScene.getState().nodes)).toHaveLength(4)
|
|
214
|
+
await Bun.sleep(1100)
|
|
215
|
+
expect(statuses).not.toContain('error')
|
|
216
|
+
expect(writes).toEqual([4])
|
|
217
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('autosave permits a single-node deletion on exit', async () => {
|
|
221
|
+
const { writes, statuses } = await mount(5)
|
|
222
|
+
useScene.getState().deleteNode('column_autosave_1')
|
|
223
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
224
|
+
expect(statuses).not.toContain('error')
|
|
225
|
+
expect(writes).toEqual([4])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('autosave blocks a full wipe and resumes after an allowed edit', async () => {
|
|
229
|
+
const { writes, statuses, controls } = await mount(78)
|
|
230
|
+
useScene.setState(graph(4))
|
|
231
|
+
controls.saveNow()
|
|
232
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
233
|
+
expect(statuses).toContain('error')
|
|
234
|
+
expect(writes).toEqual([])
|
|
235
|
+
useScene.setState(graph(5))
|
|
236
|
+
controls.saveNow()
|
|
237
|
+
await Bun.sleep(0)
|
|
238
|
+
expect(writes).toEqual([5])
|
|
239
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('collaboration scaffold deletion waits for persistence and reports saved', async () => {
|
|
243
|
+
const idle = Promise.withResolvers<void>()
|
|
244
|
+
const { writes, statuses } = await mount(78, false, () => idle.promise)
|
|
245
|
+
useScene.setState(graph(4))
|
|
246
|
+
await Bun.sleep(1100)
|
|
247
|
+
expect(statuses).not.toContain('error')
|
|
248
|
+
expect(writes).toEqual([4])
|
|
249
|
+
expect(statuses).toEqual(['pending', 'saving'])
|
|
250
|
+
idle.resolve()
|
|
251
|
+
await Bun.sleep(0)
|
|
252
|
+
expect(statuses).toEqual(['pending', 'saving', 'saved'])
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test.each([
|
|
256
|
+
'pagehide',
|
|
257
|
+
'unmount',
|
|
258
|
+
])('collaboration scaffold deletion still flushes on %s', async (event) => {
|
|
259
|
+
const { writes, statuses } = await mount(78, false)
|
|
260
|
+
useScene.setState(graph(4))
|
|
261
|
+
if (event === 'pagehide') window.dispatchEvent(new Event(event))
|
|
262
|
+
else {
|
|
263
|
+
await renderer!.unmount()
|
|
264
|
+
renderer = undefined
|
|
265
|
+
}
|
|
266
|
+
expect(statuses).not.toContain('error')
|
|
267
|
+
expect(writes).toEqual([4])
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('collaboration guard exemption preserves preview pause and resume', async () => {
|
|
271
|
+
const { writes, statuses, update } = await mount(78, false)
|
|
272
|
+
useScene.setState(graph(4))
|
|
273
|
+
await update({ guardAgainstSceneWipe: false, isVersionPreviewMode: true })
|
|
274
|
+
expect(statuses.at(-1)).toBe('paused')
|
|
275
|
+
await Bun.sleep(1100)
|
|
276
|
+
expect(writes).toEqual([])
|
|
277
|
+
await update({ guardAgainstSceneWipe: false, isVersionPreviewMode: false })
|
|
278
|
+
await Bun.sleep(1100)
|
|
279
|
+
expect(writes).toEqual([4])
|
|
280
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
281
|
+
expect(statuses).not.toContain('error')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('changing guard ownership recovers an error and keeps the new stored baseline', async () => {
|
|
285
|
+
const { writes, statuses, controls, update } = await mount(78)
|
|
286
|
+
useScene.setState(graph(4))
|
|
287
|
+
controls.saveNow()
|
|
288
|
+
expect(statuses.at(-1)).toBe('error')
|
|
289
|
+
await update({ guardAgainstSceneWipe: false })
|
|
290
|
+
controls.saveNow()
|
|
291
|
+
await Bun.sleep(0)
|
|
292
|
+
expect(writes).toEqual([4])
|
|
293
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
294
|
+
await update({ guardAgainstSceneWipe: true })
|
|
295
|
+
useScene.setState(graph(3))
|
|
296
|
+
controls.saveNow()
|
|
297
|
+
await Bun.sleep(0)
|
|
298
|
+
expect(writes).toEqual([4, 3])
|
|
299
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('collaboration callback failures still report error', async () => {
|
|
303
|
+
const { writes, statuses, controls } = await mount(78, false, async () => {
|
|
304
|
+
throw new Error('Collaboration failed')
|
|
305
|
+
})
|
|
306
|
+
useScene.setState(graph(4))
|
|
307
|
+
controls.saveNow()
|
|
308
|
+
await Bun.sleep(0)
|
|
309
|
+
expect(writes).toEqual([4])
|
|
310
|
+
expect(statuses).toEqual(['pending', 'saving', 'error'])
|
|
311
|
+
})
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useScene } from '@pascal-app/core'
|
|
4
|
-
import {
|
|
5
|
-
import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
|
|
4
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
5
|
+
import { getSavedSceneDocument, type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
|
|
6
6
|
|
|
7
7
|
const AUTOSAVE_DEBOUNCE_MS = 1000
|
|
8
8
|
const STRUCTURAL_NODE_COUNT = 4
|
|
9
9
|
|
|
10
10
|
export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount: number) {
|
|
11
|
-
return
|
|
11
|
+
return (
|
|
12
|
+
previousNodeCount > STRUCTURAL_NODE_COUNT &&
|
|
13
|
+
currentNodeCount <= STRUCTURAL_NODE_COUNT &&
|
|
14
|
+
previousNodeCount - currentNodeCount > 1
|
|
15
|
+
)
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
/**
|
|
@@ -37,8 +41,8 @@ export function createStoredNodeCountTracker(initialNodeCount: number) {
|
|
|
37
41
|
* which is an accidental full deletion far more often than an intent. The
|
|
38
42
|
* caller reports the block; on `true` the write becomes the new baseline.
|
|
39
43
|
*/
|
|
40
|
-
allowWrite(nodeCount: number) {
|
|
41
|
-
if (isSuspiciousNodeDrop(count, nodeCount)) return false
|
|
44
|
+
allowWrite(nodeCount: number, guardAgainstSceneWipe = true) {
|
|
45
|
+
if (guardAgainstSceneWipe && isSuspiciousNodeDrop(count, nodeCount)) return false
|
|
42
46
|
count = nodeCount
|
|
43
47
|
return true
|
|
44
48
|
},
|
|
@@ -65,10 +69,14 @@ export function decideExitFlush(opts: {
|
|
|
65
69
|
hasDirtyChanges: boolean
|
|
66
70
|
storedNodeCount: number
|
|
67
71
|
currentNodeCount: number
|
|
72
|
+
guardAgainstSceneWipe?: boolean
|
|
68
73
|
}): ExitFlushDecision {
|
|
69
74
|
if (!opts.hasDirtyChanges) return 'skip-clean'
|
|
70
75
|
if (opts.isLoadingScene) return 'skip-loading'
|
|
71
|
-
if (
|
|
76
|
+
if (
|
|
77
|
+
opts.guardAgainstSceneWipe !== false &&
|
|
78
|
+
isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)
|
|
79
|
+
) {
|
|
72
80
|
return 'blocked-suspicious'
|
|
73
81
|
}
|
|
74
82
|
return 'flush'
|
|
@@ -77,6 +85,7 @@ export function decideExitFlush(opts: {
|
|
|
77
85
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
|
78
86
|
|
|
79
87
|
interface UseAutoSaveOptions {
|
|
88
|
+
guardAgainstSceneWipe?: boolean
|
|
80
89
|
onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
|
|
81
90
|
onDirty?: () => void
|
|
82
91
|
onSaveStatusChange?: (status: SaveStatus) => void
|
|
@@ -90,12 +99,14 @@ interface UseAutoSaveOptions {
|
|
|
90
99
|
* ⚠️ Mount in exactly ONE component (the Editor).
|
|
91
100
|
*/
|
|
92
101
|
export function useAutoSave({
|
|
102
|
+
guardAgainstSceneWipe = true,
|
|
93
103
|
onSave,
|
|
94
104
|
onDirty,
|
|
95
105
|
onSaveStatusChange,
|
|
96
106
|
isVersionPreviewMode = false,
|
|
97
107
|
}: UseAutoSaveOptions): {
|
|
98
|
-
|
|
108
|
+
beginSceneLoad: () => void
|
|
109
|
+
completeSceneLoad: () => void
|
|
99
110
|
saveNow: () => void
|
|
100
111
|
} {
|
|
101
112
|
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
@@ -116,6 +127,7 @@ export function useAutoSave({
|
|
|
116
127
|
const onDirtyRef = useRef(onDirty)
|
|
117
128
|
const onSaveStatusChangeRef = useRef(onSaveStatusChange)
|
|
118
129
|
const isVersionPreviewModeRef = useRef(isVersionPreviewMode)
|
|
130
|
+
const guardAgainstSceneWipeRef = useRef(guardAgainstSceneWipe)
|
|
119
131
|
|
|
120
132
|
useEffect(() => {
|
|
121
133
|
onSaveRef.current = onSave
|
|
@@ -129,6 +141,9 @@ export function useAutoSave({
|
|
|
129
141
|
useEffect(() => {
|
|
130
142
|
isVersionPreviewModeRef.current = isVersionPreviewMode
|
|
131
143
|
}, [isVersionPreviewMode])
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
guardAgainstSceneWipeRef.current = guardAgainstSceneWipe
|
|
146
|
+
}, [guardAgainstSceneWipe])
|
|
132
147
|
|
|
133
148
|
const setSaveStatus = useCallback((status: SaveStatus) => {
|
|
134
149
|
onSaveStatusChangeRef.current?.(status)
|
|
@@ -155,7 +170,8 @@ export function useAutoSave({
|
|
|
155
170
|
return
|
|
156
171
|
}
|
|
157
172
|
|
|
158
|
-
const { nodes, rootNodeIds, collections, materials, installedPlugins } =
|
|
173
|
+
const { nodes, rootNodeIds, collections, materials, installedPlugins } =
|
|
174
|
+
getSavedSceneDocument()
|
|
159
175
|
const sceneGraph = {
|
|
160
176
|
nodes,
|
|
161
177
|
rootNodeIds,
|
|
@@ -166,7 +182,7 @@ export function useAutoSave({
|
|
|
166
182
|
|
|
167
183
|
const currentNodeCount = Object.keys(nodes).length
|
|
168
184
|
const previousNodeCount = storedNodeCount.count
|
|
169
|
-
if (!storedNodeCount.allowWrite(currentNodeCount)) {
|
|
185
|
+
if (!storedNodeCount.allowWrite(currentNodeCount, guardAgainstSceneWipeRef.current)) {
|
|
170
186
|
console.warn(
|
|
171
187
|
`[autosave] Blocked: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
172
188
|
)
|
|
@@ -223,18 +239,23 @@ export function useAutoSave({
|
|
|
223
239
|
return
|
|
224
240
|
}
|
|
225
241
|
|
|
242
|
+
// A plugin set the user never chose is the host's defaults, re-derived on
|
|
243
|
+
// every load: a plugin registering after the load (hosted plugins arrive
|
|
244
|
+
// late) syncs it without anyone editing the scene.
|
|
245
|
+
const pluginsEdited =
|
|
246
|
+
state.installedPlugins !== lastInstalledPluginsRef && state.hasExplicitPluginInstallState
|
|
247
|
+
lastInstalledPluginsRef = state.installedPlugins
|
|
226
248
|
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
|
227
249
|
const changed =
|
|
228
250
|
currentNodesSnapshot !== lastNodesSnapshot ||
|
|
229
251
|
state.collections !== lastCollectionsRef ||
|
|
230
252
|
state.materials !== lastMaterialsRef ||
|
|
231
|
-
|
|
253
|
+
pluginsEdited
|
|
232
254
|
if (!changed) return
|
|
233
255
|
|
|
234
256
|
lastNodesSnapshot = currentNodesSnapshot
|
|
235
257
|
lastCollectionsRef = state.collections
|
|
236
258
|
lastMaterialsRef = state.materials
|
|
237
|
-
lastInstalledPluginsRef = state.installedPlugins
|
|
238
259
|
hasDirtyChangesRef.current = true
|
|
239
260
|
onDirtyRef.current?.()
|
|
240
261
|
setSaveStatus('pending')
|
|
@@ -258,7 +279,8 @@ export function useAutoSave({
|
|
|
258
279
|
// would otherwise drop the change entirely. `pagehide` fires in cases
|
|
259
280
|
// (mobile Safari, bfcache) where `beforeunload` does not.
|
|
260
281
|
function flushOnExit() {
|
|
261
|
-
const { nodes, rootNodeIds, collections, materials, installedPlugins } =
|
|
282
|
+
const { nodes, rootNodeIds, collections, materials, installedPlugins } =
|
|
283
|
+
getSavedSceneDocument()
|
|
262
284
|
const currentNodeCount = Object.keys(nodes).length
|
|
263
285
|
const previousNodeCount = storedNodeCount.count
|
|
264
286
|
const decision = decideExitFlush({
|
|
@@ -266,6 +288,7 @@ export function useAutoSave({
|
|
|
266
288
|
hasDirtyChanges: hasDirtyChangesRef.current,
|
|
267
289
|
storedNodeCount: previousNodeCount,
|
|
268
290
|
currentNodeCount,
|
|
291
|
+
guardAgainstSceneWipe: guardAgainstSceneWipeRef.current,
|
|
269
292
|
})
|
|
270
293
|
if (decision === 'skip-clean') return
|
|
271
294
|
if (decision === 'skip-loading') {
|
|
@@ -282,7 +305,7 @@ export function useAutoSave({
|
|
|
282
305
|
return
|
|
283
306
|
}
|
|
284
307
|
// 'flush' — adopt the write as the new stored baseline.
|
|
285
|
-
storedNodeCount.allowWrite(currentNodeCount)
|
|
308
|
+
storedNodeCount.allowWrite(currentNodeCount, guardAgainstSceneWipeRef.current)
|
|
286
309
|
|
|
287
310
|
hasDirtyChangesRef.current = false
|
|
288
311
|
const sceneGraph = {
|
|
@@ -361,5 +384,23 @@ export function useAutoSave({
|
|
|
361
384
|
executeSaveRef.current?.()
|
|
362
385
|
}, [])
|
|
363
386
|
|
|
364
|
-
|
|
387
|
+
const beginSceneLoad = useCallback(() => {
|
|
388
|
+
isLoadingSceneRef.current = true
|
|
389
|
+
}, [])
|
|
390
|
+
|
|
391
|
+
// Call as soon as the store holds the loaded graph — hydration is data, not
|
|
392
|
+
// a frame. Gating this on requestAnimationFrame kept a tab loaded while
|
|
393
|
+
// hidden (no frames ever run) "loading" indefinitely: every later edit was
|
|
394
|
+
// adopted as the loaded baseline, so nothing autosaved and the exit flush
|
|
395
|
+
// had nothing dirty to write.
|
|
396
|
+
const completeSceneLoad = useCallback(() => {
|
|
397
|
+
isLoadingSceneRef.current = false
|
|
398
|
+
// A save that came due mid-load was deferred; the store now holds real
|
|
399
|
+
// data, so write it rather than leave the status parked on 'paused'.
|
|
400
|
+
if (pendingSaveRef.current && !isSavingRef.current && !saveTimeoutRef.current) {
|
|
401
|
+
executeSaveRef.current?.()
|
|
402
|
+
}
|
|
403
|
+
}, [])
|
|
404
|
+
|
|
405
|
+
return { beginSceneLoad, completeSceneLoad, saveNow }
|
|
365
406
|
}
|