@pascal-app/editor 1.0.0-beta.5 → 1.0.1
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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +78 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -239
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +304 -171
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +56 -17
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +63 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +44 -21
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +103 -10
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +5 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +455 -0
- package/src/lib/portable-export.ts +1042 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +38 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-send-to-app.ts +21 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createStoredNodeCountTracker,
|
|
4
|
+
decideExitFlush,
|
|
5
|
+
isSuspiciousNodeDrop,
|
|
6
|
+
} from './use-auto-save'
|
|
3
7
|
|
|
4
8
|
describe('isSuspiciousNodeDrop', () => {
|
|
5
9
|
test('blocks populated scenes from being flushed as empty skeletons', () => {
|
|
@@ -61,3 +65,71 @@ describe('createStoredNodeCountTracker', () => {
|
|
|
61
65
|
expect(tracker.allowWrite(3)).toBe(true)
|
|
62
66
|
})
|
|
63
67
|
})
|
|
68
|
+
|
|
69
|
+
describe('decideExitFlush', () => {
|
|
70
|
+
test('reproduces the 2026-08-16 scene-wipe sequence and skips the flush', () => {
|
|
71
|
+
// The exact traced wipe (dev repro, scenes a4993ec9f1ab/1befee38f973 and
|
|
72
|
+
// the live sessions of 2026-08-18):
|
|
73
|
+
// 1. useAutoSave subscribes; store = initial empty state.
|
|
74
|
+
// 2. useHostPanels' mount effect writes default installedPlugins — a
|
|
75
|
+
// scene-store change BEFORE the Editor's load effect runs, so the
|
|
76
|
+
// session is marked dirty with zero user edits.
|
|
77
|
+
// 3. The load effect sets loading=true and calls unloadScene(); the
|
|
78
|
+
// tracker re-baselines to the transient 0-node state.
|
|
79
|
+
// 4. StrictMode's simulated unmount (prod: tab close / navigation)
|
|
80
|
+
// runs the effect cleanup -> flushOnExit with an EMPTY store.
|
|
81
|
+
// The flush must be skipped: the store content is transient, not data.
|
|
82
|
+
expect(
|
|
83
|
+
decideExitFlush({
|
|
84
|
+
isLoadingScene: true,
|
|
85
|
+
hasDirtyChanges: true,
|
|
86
|
+
storedNodeCount: 0,
|
|
87
|
+
currentNodeCount: 0,
|
|
88
|
+
}),
|
|
89
|
+
).toBe('skip-loading')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('never flushes while a load is in flight, whatever the counts say', () => {
|
|
93
|
+
expect(
|
|
94
|
+
decideExitFlush({
|
|
95
|
+
isLoadingScene: true,
|
|
96
|
+
hasDirtyChanges: true,
|
|
97
|
+
storedNodeCount: 74,
|
|
98
|
+
currentNodeCount: 74,
|
|
99
|
+
}),
|
|
100
|
+
).toBe('skip-loading')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('does nothing when there are no dirty changes', () => {
|
|
104
|
+
expect(
|
|
105
|
+
decideExitFlush({
|
|
106
|
+
isLoadingScene: false,
|
|
107
|
+
hasDirtyChanges: false,
|
|
108
|
+
storedNodeCount: 74,
|
|
109
|
+
currentNodeCount: 0,
|
|
110
|
+
}),
|
|
111
|
+
).toBe('skip-clean')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('blocks a populated-to-scaffold drop after hydration', () => {
|
|
115
|
+
expect(
|
|
116
|
+
decideExitFlush({
|
|
117
|
+
isLoadingScene: false,
|
|
118
|
+
hasDirtyChanges: true,
|
|
119
|
+
storedNodeCount: 74,
|
|
120
|
+
currentNodeCount: 0,
|
|
121
|
+
}),
|
|
122
|
+
).toBe('blocked-suspicious')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('flushes ordinary dirty sessions on exit', () => {
|
|
126
|
+
expect(
|
|
127
|
+
decideExitFlush({
|
|
128
|
+
isLoadingScene: false,
|
|
129
|
+
hasDirtyChanges: true,
|
|
130
|
+
storedNodeCount: 74,
|
|
131
|
+
currentNodeCount: 75,
|
|
132
|
+
}),
|
|
133
|
+
).toBe('flush')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -45,6 +45,35 @@ export function createStoredNodeCountTracker(initialNodeCount: number) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export type ExitFlushDecision = 'skip-clean' | 'skip-loading' | 'blocked-suspicious' | 'flush'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decides what the unload/unmount flush may do with the store's current
|
|
52
|
+
* content. Pure so the wipe scenarios stay unit-testable.
|
|
53
|
+
*
|
|
54
|
+
* `skip-loading` is the load-bearing branch: while a scene load is in flight
|
|
55
|
+
* the store passes through an intermediate `unloadScene()` state — zero nodes,
|
|
56
|
+
* zero roots — that is NOT user data. A flush fired in that window (StrictMode
|
|
57
|
+
* simulated unmount in dev, a quick tab close or navigation in prod) used to
|
|
58
|
+
* serialize that empty store and PUT it over the server copy, wiping the scene
|
|
59
|
+
* at v2. The dirty flag alone cannot protect here: document-level writes that
|
|
60
|
+
* land before hydration (e.g. the host-panel default `installedPlugins` sync)
|
|
61
|
+
* mark the session dirty without any user edit.
|
|
62
|
+
*/
|
|
63
|
+
export function decideExitFlush(opts: {
|
|
64
|
+
isLoadingScene: boolean
|
|
65
|
+
hasDirtyChanges: boolean
|
|
66
|
+
storedNodeCount: number
|
|
67
|
+
currentNodeCount: number
|
|
68
|
+
}): ExitFlushDecision {
|
|
69
|
+
if (!opts.hasDirtyChanges) return 'skip-clean'
|
|
70
|
+
if (opts.isLoadingScene) return 'skip-loading'
|
|
71
|
+
if (isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)) {
|
|
72
|
+
return 'blocked-suspicious'
|
|
73
|
+
}
|
|
74
|
+
return 'flush'
|
|
75
|
+
}
|
|
76
|
+
|
|
48
77
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
|
49
78
|
|
|
50
79
|
interface UseAutoSaveOptions {
|
|
@@ -65,10 +94,19 @@ export function useAutoSave({
|
|
|
65
94
|
onDirty,
|
|
66
95
|
onSaveStatusChange,
|
|
67
96
|
isVersionPreviewMode = false,
|
|
68
|
-
}: UseAutoSaveOptions): {
|
|
97
|
+
}: UseAutoSaveOptions): {
|
|
98
|
+
isLoadingSceneRef: MutableRefObject<boolean>
|
|
99
|
+
saveNow: () => void
|
|
100
|
+
} {
|
|
69
101
|
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
70
102
|
const isSavingRef = useRef(false)
|
|
71
|
-
|
|
103
|
+
// Starts TRUE: the scene is "loading" from mount until the Editor's load
|
|
104
|
+
// effect completes its first hydration. The Editor's load effect runs
|
|
105
|
+
// several hooks AFTER this one (hook order), so store writes in that gap —
|
|
106
|
+
// e.g. `useHostPanels` syncing default `installedPlugins` on mount — must
|
|
107
|
+
// not mark the session dirty or arm a save: the store still holds the empty
|
|
108
|
+
// pre-hydration state, and flushing it wipes the scene server-side.
|
|
109
|
+
const isLoadingSceneRef = useRef(true)
|
|
72
110
|
const pendingSaveRef = useRef(false)
|
|
73
111
|
const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
|
|
74
112
|
const hasDirtyChangesRef = useRef(false)
|
|
@@ -220,17 +258,31 @@ export function useAutoSave({
|
|
|
220
258
|
// would otherwise drop the change entirely. `pagehide` fires in cases
|
|
221
259
|
// (mobile Safari, bfcache) where `beforeunload` does not.
|
|
222
260
|
function flushOnExit() {
|
|
223
|
-
if (!hasDirtyChangesRef.current) return
|
|
224
261
|
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
|
|
225
262
|
const currentNodeCount = Object.keys(nodes).length
|
|
226
263
|
const previousNodeCount = storedNodeCount.count
|
|
227
|
-
|
|
264
|
+
const decision = decideExitFlush({
|
|
265
|
+
isLoadingScene: isLoadingSceneRef.current,
|
|
266
|
+
hasDirtyChanges: hasDirtyChangesRef.current,
|
|
267
|
+
storedNodeCount: previousNodeCount,
|
|
268
|
+
currentNodeCount,
|
|
269
|
+
})
|
|
270
|
+
if (decision === 'skip-clean') return
|
|
271
|
+
if (decision === 'skip-loading') {
|
|
272
|
+
console.warn(
|
|
273
|
+
'[autosave] Skipped unload flush: a scene load is in flight, the store content is transient. Nothing user-authored is lost.',
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
if (decision === 'blocked-suspicious') {
|
|
228
278
|
console.warn(
|
|
229
279
|
`[autosave] Blocked unload flush: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
230
280
|
)
|
|
231
281
|
setSaveStatus('error')
|
|
232
282
|
return
|
|
233
283
|
}
|
|
284
|
+
// 'flush' — adopt the write as the new stored baseline.
|
|
285
|
+
storedNodeCount.allowWrite(currentNodeCount)
|
|
234
286
|
|
|
235
287
|
hasDirtyChangesRef.current = false
|
|
236
288
|
const sceneGraph = {
|
|
@@ -290,5 +342,24 @@ export function useAutoSave({
|
|
|
290
342
|
setSaveStatus('saved')
|
|
291
343
|
}, [isVersionPreviewMode, setSaveStatus])
|
|
292
344
|
|
|
293
|
-
|
|
345
|
+
// Imperative flush for the save shortcut: drop the debounce and write now,
|
|
346
|
+
// through the same `executeSave` so the wipe guard and status callbacks stay
|
|
347
|
+
// in the loop. A write already in flight only arms the follow-up.
|
|
348
|
+
const saveNow = useCallback(() => {
|
|
349
|
+
if (isLoadingSceneRef.current) return
|
|
350
|
+
|
|
351
|
+
if (saveTimeoutRef.current) {
|
|
352
|
+
clearTimeout(saveTimeoutRef.current)
|
|
353
|
+
saveTimeoutRef.current = undefined
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (isSavingRef.current) {
|
|
357
|
+
pendingSaveRef.current = true
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
executeSaveRef.current?.()
|
|
362
|
+
}, [])
|
|
363
|
+
|
|
364
|
+
return { isLoadingSceneRef, saveNow }
|
|
294
365
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type CeilingEvent,
|
|
4
|
+
CeilingNode,
|
|
5
|
+
emitter,
|
|
6
|
+
LevelNode,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { ProceduralItemNode, shelfRecipe } from '@pascal-app/core/procedural-items'
|
|
11
|
+
import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
|
|
12
|
+
import { _roots, act, createRoot } from '@react-three/fiber'
|
|
13
|
+
import { createElement } from 'react'
|
|
14
|
+
import {
|
|
15
|
+
Mesh,
|
|
16
|
+
MeshBasicMaterial,
|
|
17
|
+
PerspectiveCamera,
|
|
18
|
+
PlaneGeometry,
|
|
19
|
+
type WebGLRenderer,
|
|
20
|
+
} from 'three'
|
|
21
|
+
import useEditor from '../store/use-editor'
|
|
22
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
23
|
+
import { useCeilingEvents } from './use-ceiling-events'
|
|
24
|
+
|
|
25
|
+
test.each([
|
|
26
|
+
'item',
|
|
27
|
+
'procedural-item',
|
|
28
|
+
])('%s ceiling placement keeps move and commit hits while an unhovered ceiling is batched', async (kind) => {
|
|
29
|
+
const previousViewer = useViewer.getState()
|
|
30
|
+
const previousEditor = useEditor.getState()
|
|
31
|
+
const previousInteraction = useInteractionScope.getState()
|
|
32
|
+
const previousScene = useScene.getState()
|
|
33
|
+
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
34
|
+
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
|
|
35
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = true
|
|
36
|
+
const canvas = Object.assign(new EventTarget(), {
|
|
37
|
+
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
|
|
38
|
+
}) as unknown as HTMLCanvasElement
|
|
39
|
+
const root = createRoot(canvas)
|
|
40
|
+
const camera = new PerspectiveCamera(60, 1, 0.1, 100)
|
|
41
|
+
camera.position.set(0, 1, 5)
|
|
42
|
+
camera.lookAt(0, 3, 0)
|
|
43
|
+
camera.updateMatrixWorld()
|
|
44
|
+
const level = LevelNode.parse({ id: 'level_ceiling_batch' })
|
|
45
|
+
const ceiling = CeilingNode.parse({
|
|
46
|
+
id: 'ceiling_placement_batch',
|
|
47
|
+
parentId: level.id,
|
|
48
|
+
polygon: [
|
|
49
|
+
[-5, -5],
|
|
50
|
+
[5, -5],
|
|
51
|
+
[5, 5],
|
|
52
|
+
[-5, 5],
|
|
53
|
+
],
|
|
54
|
+
})
|
|
55
|
+
const surface = new Mesh(new PlaneGeometry(10, 10).rotateX(-Math.PI / 2), new MeshBasicMaterial())
|
|
56
|
+
surface.position.y = 3
|
|
57
|
+
surface.updateMatrixWorld(true)
|
|
58
|
+
const moves: CeilingEvent[] = []
|
|
59
|
+
const clicks: CeilingEvent[] = []
|
|
60
|
+
const onMove = (event: CeilingEvent) => moves.push(event)
|
|
61
|
+
const onClick = (event: CeilingEvent) => clicks.push(event)
|
|
62
|
+
emitter.on('ceiling:move', onMove)
|
|
63
|
+
emitter.on('ceiling:click', onClick)
|
|
64
|
+
function Placement() {
|
|
65
|
+
useCeilingEvents()
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
const send = () => {
|
|
69
|
+
for (const type of ['pointermove', 'click']) {
|
|
70
|
+
canvas.dispatchEvent(Object.assign(new Event(type), { clientX: 55, clientY: 50, button: 0 }))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
sceneRegistry.nodes.set(ceiling.id, surface)
|
|
75
|
+
sceneRegistry.byType.ceiling!.add(ceiling.id)
|
|
76
|
+
useScene.setState({ nodes: { [level.id]: level, [ceiling.id]: ceiling } })
|
|
77
|
+
useViewer.setState({
|
|
78
|
+
hoveredId: null,
|
|
79
|
+
cameraDragging: false,
|
|
80
|
+
selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
|
|
81
|
+
})
|
|
82
|
+
if (kind === 'item') useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
|
|
83
|
+
else {
|
|
84
|
+
useEditor.setState({ selectedItem: null })
|
|
85
|
+
useEditor.getState().setMovingNode(
|
|
86
|
+
ProceduralItemNode.parse({
|
|
87
|
+
recipe: {
|
|
88
|
+
...shelfRecipe,
|
|
89
|
+
mounting: { attachTo: 'ceiling', reference: 'top' },
|
|
90
|
+
surfaces: [
|
|
91
|
+
{ id: 'top', label: 'Top', position: [0, 'height', 0], size: ['width', 'depth'] },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
parentId: ceiling.id,
|
|
95
|
+
}) as never,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
await root.configure({
|
|
99
|
+
gl: {
|
|
100
|
+
domElement: canvas,
|
|
101
|
+
render() {},
|
|
102
|
+
setSize() {},
|
|
103
|
+
setPixelRatio() {},
|
|
104
|
+
} as unknown as WebGLRenderer,
|
|
105
|
+
camera,
|
|
106
|
+
frameloop: 'never',
|
|
107
|
+
dpr: 1,
|
|
108
|
+
size: { width: 100, height: 100, top: 0, left: 0 },
|
|
109
|
+
})
|
|
110
|
+
await act(async () => {
|
|
111
|
+
root.render(createElement(Placement))
|
|
112
|
+
})
|
|
113
|
+
send()
|
|
114
|
+
hideFromScene(surface, 'batched')
|
|
115
|
+
send()
|
|
116
|
+
showInScene(surface, 'batched')
|
|
117
|
+
send()
|
|
118
|
+
expect(moves).toHaveLength(3)
|
|
119
|
+
expect(clicks).toHaveLength(3)
|
|
120
|
+
expect(moves[0]!.position[1]).toBeCloseTo(3)
|
|
121
|
+
for (const hit of [...moves, ...clicks]) {
|
|
122
|
+
expect(hit.node.id).toBe(ceiling.id)
|
|
123
|
+
expect(hit.position).toEqual(moves[0]!.position)
|
|
124
|
+
expect(hit.localPosition).toEqual(moves[0]!.localPosition)
|
|
125
|
+
}
|
|
126
|
+
expect(useViewer.getState().hoveredId).toBeNull()
|
|
127
|
+
} finally {
|
|
128
|
+
await act(async () => {
|
|
129
|
+
root.render(null)
|
|
130
|
+
})
|
|
131
|
+
_roots.delete(canvas)
|
|
132
|
+
emitter.off('ceiling:move', onMove)
|
|
133
|
+
emitter.off('ceiling:click', onClick)
|
|
134
|
+
sceneRegistry.nodes.delete(ceiling.id)
|
|
135
|
+
sceneRegistry.byType.ceiling!.delete(ceiling.id)
|
|
136
|
+
surface.geometry.dispose()
|
|
137
|
+
surface.material.dispose()
|
|
138
|
+
useScene.setState(previousScene)
|
|
139
|
+
useEditor.setState(previousEditor)
|
|
140
|
+
useInteractionScope.setState(previousInteraction)
|
|
141
|
+
useViewer.setState(previousViewer)
|
|
142
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
|
|
143
|
+
}
|
|
144
|
+
})
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sceneRegistry,
|
|
8
8
|
useScene,
|
|
9
9
|
} from '@pascal-app/core'
|
|
10
|
+
import { isProceduralItem } from '@pascal-app/core/procedural-items'
|
|
10
11
|
import { useViewer } from '@pascal-app/viewer'
|
|
11
12
|
import { useThree } from '@react-three/fiber'
|
|
12
13
|
import { useEffect, useRef } from 'react'
|
|
@@ -61,7 +62,10 @@ export function useCeilingEvents() {
|
|
|
61
62
|
const ed = useEditor.getState()
|
|
62
63
|
if (ed.selectedItem?.attachTo === 'ceiling') return true
|
|
63
64
|
const moving = getMovingNode()
|
|
64
|
-
return
|
|
65
|
+
return (
|
|
66
|
+
(moving?.type === 'item' && moving.asset?.attachTo === 'ceiling') ||
|
|
67
|
+
(isProceduralItem(moving) && moving.recipe.mounting?.attachTo === 'ceiling')
|
|
68
|
+
)
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 }
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type SpatialQuery,
|
|
15
15
|
useScene,
|
|
16
16
|
} from '@pascal-app/core'
|
|
17
|
+
import { beginPerfAction, cancelPerfAction, commitPerfAction } from '@pascal-app/viewer'
|
|
17
18
|
import { useEffect, useRef } from 'react'
|
|
18
19
|
|
|
19
20
|
const sceneApi = createSceneApi(useScene)
|
|
@@ -74,14 +75,26 @@ export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
|
|
|
74
75
|
useEffect(() => {
|
|
75
76
|
if (!args.active) return
|
|
76
77
|
|
|
78
|
+
const initial = argsRef.current.initial
|
|
79
|
+
const nodeType = initial.node?.type
|
|
80
|
+
const isEndpoint = initial.handleId === 'start' || initial.handleId === 'end'
|
|
81
|
+
const actionName = isEndpoint && nodeType ? `drag:${nodeType}-endpoint` : 'drag:move'
|
|
82
|
+
|
|
77
83
|
const session = createDragSession<Ctx, Draft>(argsRef.current.action, sceneApi, {
|
|
78
84
|
spatialQuery: argsRef.current.spatialQuery,
|
|
79
85
|
childQuery: argsRef.current.childQuery,
|
|
80
|
-
onCommit: () =>
|
|
81
|
-
|
|
86
|
+
onCommit: () => {
|
|
87
|
+
commitPerfAction()
|
|
88
|
+
argsRef.current.onCommit?.()
|
|
89
|
+
},
|
|
90
|
+
onCancel: () => {
|
|
91
|
+
cancelPerfAction()
|
|
92
|
+
argsRef.current.onCancel?.()
|
|
93
|
+
},
|
|
82
94
|
})
|
|
83
95
|
|
|
84
|
-
|
|
96
|
+
beginPerfAction(actionName, initial.node?.id ?? nodeType ?? '')
|
|
97
|
+
session.start(initial)
|
|
85
98
|
|
|
86
99
|
const activatedAt = Date.now()
|
|
87
100
|
const graceMs = argsRef.current.activationGraceMs ?? 150
|
|
@@ -119,6 +132,7 @@ export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
|
|
|
119
132
|
}
|
|
120
133
|
// If the parent flipped `active` to false (or unmounted) while we were
|
|
121
134
|
// still mid-drag, treat it as a cancel — no dangling history pause.
|
|
135
|
+
if (session.isActive()) cancelPerfAction()
|
|
122
136
|
session.dispose()
|
|
123
137
|
}
|
|
124
138
|
}, [args.active])
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { expect, spyOn, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
CeilingNode,
|
|
4
|
+
emitter,
|
|
5
|
+
type GridEvent,
|
|
6
|
+
nodeRegistry,
|
|
7
|
+
registerNode,
|
|
8
|
+
SlabNode,
|
|
9
|
+
useRegistry,
|
|
10
|
+
WallNode,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
|
|
13
|
+
import { _roots, act, createRoot } from '@react-three/fiber'
|
|
14
|
+
import { createElement } from 'react'
|
|
15
|
+
import {
|
|
16
|
+
DoubleSide,
|
|
17
|
+
Mesh,
|
|
18
|
+
MeshBasicMaterial,
|
|
19
|
+
PerspectiveCamera,
|
|
20
|
+
PlaneGeometry,
|
|
21
|
+
type WebGLRenderer,
|
|
22
|
+
} from 'three'
|
|
23
|
+
import { DRAFTING_SURFACE_EXTENSION_KEY } from '../lib/interaction/registered-drafting'
|
|
24
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
25
|
+
import { useGridEvents } from './use-grid-events'
|
|
26
|
+
|
|
27
|
+
test('grid moves throttle camera drags at 100 ms and resume immediately during tool drags', async () => {
|
|
28
|
+
const previousViewer = useViewer.getState()
|
|
29
|
+
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
30
|
+
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
|
|
31
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = true
|
|
32
|
+
let now = 0
|
|
33
|
+
const clock = spyOn(performance, 'now').mockImplementation(() => now)
|
|
34
|
+
let rectReads = 0
|
|
35
|
+
const canvas = Object.assign(new EventTarget(), {
|
|
36
|
+
getBoundingClientRect() {
|
|
37
|
+
rectReads++
|
|
38
|
+
return { left: 0, top: 0, width: 100, height: 100 }
|
|
39
|
+
},
|
|
40
|
+
}) as unknown as HTMLCanvasElement
|
|
41
|
+
const root = createRoot(canvas)
|
|
42
|
+
const camera = new PerspectiveCamera(60, 1, 0.1, 100)
|
|
43
|
+
camera.position.set(0, 10, 10)
|
|
44
|
+
camera.lookAt(0, 0, 0)
|
|
45
|
+
camera.updateMatrixWorld()
|
|
46
|
+
const delivered: GridEvent[] = []
|
|
47
|
+
const onMove = (event: GridEvent) => delivered.push(event)
|
|
48
|
+
emitter.on('grid:move', onMove)
|
|
49
|
+
|
|
50
|
+
function Grid() {
|
|
51
|
+
useGridEvents(5)
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const send = (clientX = 50) => {
|
|
56
|
+
canvas.dispatchEvent(
|
|
57
|
+
Object.assign(new Event('pointermove'), { clientX, clientY: 50, button: 0 }),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
useViewer.setState({
|
|
63
|
+
cameraDragging: false,
|
|
64
|
+
inputDragging: false,
|
|
65
|
+
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
|
66
|
+
})
|
|
67
|
+
await root.configure({
|
|
68
|
+
gl: {
|
|
69
|
+
domElement: canvas,
|
|
70
|
+
render() {},
|
|
71
|
+
setSize() {},
|
|
72
|
+
setPixelRatio() {},
|
|
73
|
+
} as unknown as WebGLRenderer,
|
|
74
|
+
camera,
|
|
75
|
+
frameloop: 'never',
|
|
76
|
+
dpr: 1,
|
|
77
|
+
size: { width: 100, height: 100, top: 0, left: 0 },
|
|
78
|
+
})
|
|
79
|
+
await act(async () => {
|
|
80
|
+
root.render(createElement(Grid))
|
|
81
|
+
})
|
|
82
|
+
send()
|
|
83
|
+
expect(rectReads).toBe(1)
|
|
84
|
+
expect(delivered).toHaveLength(1)
|
|
85
|
+
const initial = delivered[0]!
|
|
86
|
+
expect(initial.position[1]).toBeCloseTo(5)
|
|
87
|
+
expect(initial.localPosition).toEqual(initial.position)
|
|
88
|
+
|
|
89
|
+
for (const inputDragging of [false, true]) {
|
|
90
|
+
const before = delivered.length
|
|
91
|
+
useViewer.setState({ cameraDragging: true, inputDragging })
|
|
92
|
+
send(55)
|
|
93
|
+
expect(delivered).toHaveLength(before + 1)
|
|
94
|
+
for (now = 1; now < 100; now++) send(60)
|
|
95
|
+
expect(rectReads).toBe(before + 1)
|
|
96
|
+
expect(delivered).toHaveLength(before + 1)
|
|
97
|
+
|
|
98
|
+
now = 100
|
|
99
|
+
send(60)
|
|
100
|
+
expect(delivered).toHaveLength(before + 2)
|
|
101
|
+
expect(delivered.at(-1)!.position).not.toEqual(delivered.at(-2)!.position)
|
|
102
|
+
now = 101
|
|
103
|
+
useViewer.setState({ cameraDragging: false })
|
|
104
|
+
send()
|
|
105
|
+
expect(delivered).toHaveLength(before + 3)
|
|
106
|
+
expect(rectReads).toBe(delivered.length)
|
|
107
|
+
expect(delivered.at(-1)!.position).toEqual(initial.position)
|
|
108
|
+
expect(delivered.at(-1)!.localPosition).toEqual(initial.localPosition)
|
|
109
|
+
now = 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await act(async () => {
|
|
113
|
+
root.render(null)
|
|
114
|
+
})
|
|
115
|
+
const before = delivered.length
|
|
116
|
+
send()
|
|
117
|
+
expect(delivered).toHaveLength(before)
|
|
118
|
+
expect(rectReads).toBe(before)
|
|
119
|
+
} finally {
|
|
120
|
+
await act(async () => {
|
|
121
|
+
root.render(null)
|
|
122
|
+
})
|
|
123
|
+
clock.mockRestore()
|
|
124
|
+
emitter.off('grid:move', onMove)
|
|
125
|
+
_roots.delete(canvas)
|
|
126
|
+
useViewer.setState(previousViewer)
|
|
127
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test.each([
|
|
132
|
+
'slab',
|
|
133
|
+
'ceiling',
|
|
134
|
+
] as const)('grid moves keep the same %s hit when its source joins or leaves a batch', async (kind) => {
|
|
135
|
+
const previousViewer = useViewer.getState()
|
|
136
|
+
const previousScope = useInteractionScope.getState().scope
|
|
137
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
138
|
+
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
139
|
+
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
|
|
140
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = true
|
|
141
|
+
const canvas = Object.assign(new EventTarget(), {
|
|
142
|
+
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
|
|
143
|
+
}) as unknown as HTMLCanvasElement
|
|
144
|
+
const root = createRoot(canvas)
|
|
145
|
+
const camera = new PerspectiveCamera(60, 1, 0.1, 100)
|
|
146
|
+
camera.position.set(0, kind === 'ceiling' ? 1 : 10, 10)
|
|
147
|
+
camera.lookAt(0, 5, 0)
|
|
148
|
+
camera.updateMatrixWorld()
|
|
149
|
+
const surface = new Mesh(
|
|
150
|
+
new PlaneGeometry(20, 20).rotateX(-Math.PI / 2),
|
|
151
|
+
new MeshBasicMaterial({ side: DoubleSide }),
|
|
152
|
+
)
|
|
153
|
+
surface.position.y = 5
|
|
154
|
+
surface.updateMatrixWorld(true)
|
|
155
|
+
const surfaceId = `${kind}_grid_batch_test` as const
|
|
156
|
+
const wall = WallNode.parse({ start: [0, 0], end: [0, 2] })
|
|
157
|
+
const delivered: GridEvent[] = []
|
|
158
|
+
const onMove = (event: GridEvent) => delivered.push(event)
|
|
159
|
+
emitter.on('grid:move', onMove)
|
|
160
|
+
|
|
161
|
+
function Grid() {
|
|
162
|
+
useRegistry(surfaceId, kind, { current: surface })
|
|
163
|
+
useGridEvents(0)
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
const send = () => {
|
|
167
|
+
const before = delivered.length
|
|
168
|
+
canvas.dispatchEvent(
|
|
169
|
+
Object.assign(new Event('pointermove'), { clientX: 55, clientY: 50, button: 0 }),
|
|
170
|
+
)
|
|
171
|
+
expect(delivered).toHaveLength(before + 1)
|
|
172
|
+
return delivered.at(-1)!
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
nodeRegistry._reset()
|
|
177
|
+
registerNode({
|
|
178
|
+
kind,
|
|
179
|
+
schemaVersion: 1,
|
|
180
|
+
category: 'structure',
|
|
181
|
+
capabilities: {},
|
|
182
|
+
schema: kind === 'ceiling' ? CeilingNode : SlabNode,
|
|
183
|
+
defaults: () => ({}),
|
|
184
|
+
drafting: { surfaceQuery: true },
|
|
185
|
+
extensions: {
|
|
186
|
+
[DRAFTING_SURFACE_EXTENSION_KEY]: {
|
|
187
|
+
kind,
|
|
188
|
+
...(kind === 'ceiling' ? { raycast: 'underside' } : {}),
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
useViewer.setState({
|
|
193
|
+
cameraDragging: false,
|
|
194
|
+
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
|
195
|
+
})
|
|
196
|
+
await root.configure({
|
|
197
|
+
gl: {
|
|
198
|
+
domElement: canvas,
|
|
199
|
+
render() {},
|
|
200
|
+
setSize() {},
|
|
201
|
+
setPixelRatio() {},
|
|
202
|
+
} as unknown as WebGLRenderer,
|
|
203
|
+
camera,
|
|
204
|
+
frameloop: 'never',
|
|
205
|
+
dpr: 1,
|
|
206
|
+
size: { width: 100, height: 100, top: 0, left: 0 },
|
|
207
|
+
})
|
|
208
|
+
for (const scope of [
|
|
209
|
+
{ kind: 'moving', node: wall, nodeId: wall.id, nodeType: 'wall', view: '3d' },
|
|
210
|
+
{ kind: 'drafting', tool: kind },
|
|
211
|
+
] as const) {
|
|
212
|
+
await act(async () => {
|
|
213
|
+
useInteractionScope.setState({ scope })
|
|
214
|
+
root.render(createElement(Grid))
|
|
215
|
+
})
|
|
216
|
+
const before = send()
|
|
217
|
+
expect(before.surfaceHit?.hostId).toBe(surfaceId)
|
|
218
|
+
expect(before.position[1]).toBeCloseTo(5)
|
|
219
|
+
hideFromScene(surface, 'batched')
|
|
220
|
+
const batched = send()
|
|
221
|
+
expect(batched.surfaceHit).toEqual(before.surfaceHit)
|
|
222
|
+
expect(batched.position).toEqual(before.position)
|
|
223
|
+
expect(batched.localPosition).toEqual(before.localPosition)
|
|
224
|
+
showInScene(surface, 'batched')
|
|
225
|
+
expect(send().position).toEqual(before.position)
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
await act(async () => {
|
|
229
|
+
root.render(null)
|
|
230
|
+
})
|
|
231
|
+
emitter.off('grid:move', onMove)
|
|
232
|
+
_roots.delete(canvas)
|
|
233
|
+
surface.geometry.dispose()
|
|
234
|
+
surface.material.dispose()
|
|
235
|
+
restoreRegistry()
|
|
236
|
+
useInteractionScope.setState({ scope: previousScope })
|
|
237
|
+
useViewer.setState(previousViewer)
|
|
238
|
+
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
|
|
239
|
+
}
|
|
240
|
+
})
|