@pascal-app/editor 0.9.1 → 1.0.0-beta.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/package.json +14 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +49 -0
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +332 -199
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
- package/src/components/editor/first-person/build-collider-world.ts +52 -5
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +410 -136
- package/src/components/editor/floating-action-menu.tsx +435 -64
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +72 -0
- package/src/components/editor/floorplan-background-selection.ts +7 -20
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +2517 -1492
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +597 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +49 -6
- package/src/components/editor/index.tsx +289 -35
- package/src/components/editor/measurement-pill.tsx +68 -23
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +409 -180
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +76 -0
- package/src/components/editor/quick-measurement-hud.tsx +34 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +814 -466
- package/src/components/editor/site-edge-labels.tsx +28 -6
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +118 -253
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
- package/src/components/editor/wall-measurement-label.tsx +10 -28
- package/src/components/editor/wall-move-side-handles.tsx +229 -39
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +107 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +146 -57
- package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
- package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
- package/src/components/tools/roof/roof-tool.tsx +336 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +101 -42
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +164 -42
- package/src/components/tools/tool-manager.tsx +149 -30
- package/src/components/tools/wall/wall-drafting.test.ts +447 -0
- package/src/components/tools/wall/wall-drafting.ts +304 -108
- package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +98 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +44 -94
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +267 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +16 -3
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +93 -18
- package/src/components/ui/controls/material-picker.tsx +162 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +74 -20
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +273 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +36 -14
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +19 -4
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +36 -596
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +438 -70
- package/src/index.tsx +355 -9
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +42 -0
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +275 -0
- package/src/lib/direct-manipulation.ts +118 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +978 -0
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +52 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +283 -3
- package/src/lib/fresh-planar-placement.ts +85 -1
- package/src/lib/glb-export.test.ts +556 -0
- package/src/lib/glb-export.ts +1071 -0
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +31 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +52 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +243 -0
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +188 -0
- package/src/lib/measurements.ts +210 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +325 -0
- package/src/lib/scene-clipboard.ts +310 -21
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +313 -0
- package/src/lib/selection-routing.ts +180 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +82 -14
- package/src/lib/sfx-player.test.ts +266 -0
- package/src/lib/sfx-player.ts +262 -69
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +165 -0
- package/src/lib/snapping-mode.ts +191 -0
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +58 -86
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +665 -126
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +51 -0
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +151 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +206 -0
- package/src/store/use-interaction-scope.ts +140 -0
- package/src/store/use-measurement-draft.test.ts +559 -0
- package/src/store/use-measurement-draft.ts +570 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +53 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/floorplan/selection-tool.ts +0 -271
- package/src/lib/level-name.ts +0 -11
|
@@ -25,12 +25,15 @@ const isVec3 = (v: unknown): v is Vec3 =>
|
|
|
25
25
|
Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')
|
|
26
26
|
const isVec2 = (v: unknown): v is Vec2 =>
|
|
27
27
|
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
|
|
28
|
+
const isVec2Array = (v: unknown): v is Vec2[] => Array.isArray(v) && v.length > 0 && v.every(isVec2)
|
|
28
29
|
|
|
29
30
|
// How a participant's placement transforms rigidly around / with the group:
|
|
30
31
|
// - 'vec3' position + [x,y,z] rotation (items, …)
|
|
31
32
|
// - 'scalar' position + numeric rotation (columns)
|
|
32
33
|
// - 'endpoint' start/end tuples (walls, fences)
|
|
33
|
-
|
|
34
|
+
// - 'polygon' [x,z] vertex arrays (slabs, ceilings, zones) — the placement
|
|
35
|
+
// lives in the vertices themselves (plus optional hole rings)
|
|
36
|
+
export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint' | 'polygon'
|
|
34
37
|
|
|
35
38
|
// A selected node qualifies when it belongs to the active level's horizontal
|
|
36
39
|
// frame: either parented to that level, or declared building-scoped and parented
|
|
@@ -75,27 +78,39 @@ function getParticipantScalarRotation(node: AnyNode): number | null {
|
|
|
75
78
|
return sceneRegistry.nodes.get(node.id)?.rotation.y ?? 0
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
// Shape-only classification of a positioned placement (no level-scope check).
|
|
82
|
+
// Used for polygon hosts' attached children, whose parent is the host rather
|
|
83
|
+
// than the level.
|
|
84
|
+
function classifyPlacementShape(node: AnyNode): 'vec3' | 'scalar' | null {
|
|
85
|
+
const p = getParticipantPosition(node)
|
|
86
|
+
const r = (node as { rotation?: unknown }).rotation
|
|
87
|
+
if (isVec3(p) && isVec3(r)) return 'vec3'
|
|
88
|
+
if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar'
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
export function classifyParticipant(
|
|
79
93
|
node: AnyNode | undefined,
|
|
80
94
|
levelId: string | null,
|
|
81
95
|
sceneNodes: Record<string, AnyNode | undefined>,
|
|
82
96
|
): ParticipantKind | null {
|
|
83
97
|
if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null
|
|
84
|
-
const
|
|
85
|
-
|
|
98
|
+
const shape = classifyPlacementShape(node)
|
|
99
|
+
if (shape) return shape
|
|
86
100
|
const start = (node as { start?: unknown }).start
|
|
87
101
|
const end = (node as { end?: unknown }).end
|
|
88
|
-
if (isVec3(p) && isVec3(r)) return 'vec3'
|
|
89
|
-
if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar'
|
|
90
102
|
if (isVec2(start) && isVec2(end)) return 'endpoint'
|
|
103
|
+
if (isVec2Array((node as { polygon?: unknown }).polygon)) return 'polygon'
|
|
91
104
|
return null
|
|
92
105
|
}
|
|
93
106
|
|
|
94
|
-
// Pre-drag placement snapshot + how to transform it.
|
|
107
|
+
// Pre-drag placement snapshot + how to transform it. `holes` is null when the
|
|
108
|
+
// kind carries no holes field (zone), so patches never write one onto it.
|
|
95
109
|
export type ParticipantStart =
|
|
96
110
|
| { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 }
|
|
97
111
|
| { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number }
|
|
98
112
|
| { id: AnyNodeId; kind: 'endpoint'; start: Vec2; end: Vec2 }
|
|
113
|
+
| { id: AnyNodeId; kind: 'polygon'; polygon: Vec2[]; holes: Vec2[][] | null }
|
|
99
114
|
|
|
100
115
|
// An unselected wall/fence sharing a junction with a transforming endpoint. Only
|
|
101
116
|
// the touching endpoint(s) follow, so the neighbour stays attached while its far
|
|
@@ -143,7 +158,7 @@ export function collectParticipants(
|
|
|
143
158
|
position: [position[0], position[1], position[2]],
|
|
144
159
|
rotation,
|
|
145
160
|
})
|
|
146
|
-
} else {
|
|
161
|
+
} else if (kind === 'endpoint') {
|
|
147
162
|
const n = node as AnyNode & { start: Vec2; end: Vec2 }
|
|
148
163
|
starts.push({
|
|
149
164
|
id: id as AnyNodeId,
|
|
@@ -151,6 +166,56 @@ export function collectParticipants(
|
|
|
151
166
|
start: [n.start[0], n.start[1]],
|
|
152
167
|
end: [n.end[0], n.end[1]],
|
|
153
168
|
})
|
|
169
|
+
} else {
|
|
170
|
+
const n = node as AnyNode & { polygon: Vec2[]; holes?: Vec2[][] }
|
|
171
|
+
starts.push({
|
|
172
|
+
id: id as AnyNodeId,
|
|
173
|
+
kind,
|
|
174
|
+
polygon: n.polygon.map(([x, z]) => [x, z] as Vec2),
|
|
175
|
+
holes: Array.isArray(n.holes)
|
|
176
|
+
? n.holes.map((hole) => hole.map(([x, z]) => [x, z] as Vec2))
|
|
177
|
+
: null,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Polygon hosts (slab/ceiling) rebuild their geometry from vertices rather
|
|
183
|
+
// than transforming a group, so — unlike wall children, which ride the wall
|
|
184
|
+
// mesh — their attached children (ceiling-mounted items) must transform
|
|
185
|
+
// explicitly. Their positions are stored in the level frame (the host group
|
|
186
|
+
// sits at the origin), so the same rigid patches apply.
|
|
187
|
+
const includedIds = new Set<string>(starts.map((s) => s.id))
|
|
188
|
+
for (const s of [...starts]) {
|
|
189
|
+
if (s.kind !== 'polygon') continue
|
|
190
|
+
const host = sceneNodes[s.id]
|
|
191
|
+
const childIds = (host as { children?: string[] } | undefined)?.children
|
|
192
|
+
if (!Array.isArray(childIds)) continue
|
|
193
|
+
for (const childId of childIds) {
|
|
194
|
+
if (includedIds.has(childId)) continue
|
|
195
|
+
const child = sceneNodes[childId]
|
|
196
|
+
if (!child) continue
|
|
197
|
+
const shape = classifyPlacementShape(child)
|
|
198
|
+
const position = child ? getParticipantPosition(child) : null
|
|
199
|
+
if (!(shape && position)) continue
|
|
200
|
+
if (shape === 'vec3') {
|
|
201
|
+
const c = child as AnyNode & { rotation: Vec3 }
|
|
202
|
+
starts.push({
|
|
203
|
+
id: childId as AnyNodeId,
|
|
204
|
+
kind: 'vec3',
|
|
205
|
+
position: [position[0], position[1], position[2]],
|
|
206
|
+
rotation: [c.rotation[0], c.rotation[1], c.rotation[2]],
|
|
207
|
+
})
|
|
208
|
+
} else {
|
|
209
|
+
const rotation = getParticipantScalarRotation(child)
|
|
210
|
+
if (rotation === null) continue
|
|
211
|
+
starts.push({
|
|
212
|
+
id: childId as AnyNodeId,
|
|
213
|
+
kind: 'scalar',
|
|
214
|
+
position: [position[0], position[1], position[2]],
|
|
215
|
+
rotation,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
includedIds.add(childId)
|
|
154
219
|
}
|
|
155
220
|
}
|
|
156
221
|
|
|
@@ -219,6 +284,174 @@ export function expandToComponent(
|
|
|
219
284
|
return Array.from(included)
|
|
220
285
|
}
|
|
221
286
|
|
|
287
|
+
// Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live
|
|
288
|
+
// preview and for the single batched `updateNodes` on commit.
|
|
289
|
+
export type GroupPatch = readonly [AnyNodeId, Record<string, unknown>]
|
|
290
|
+
|
|
291
|
+
// Rigid group rotation: orbit each participant's anchor point(s) CCW by
|
|
292
|
+
// `delta` (atan2 x→z sense) around `center` (level-frame XZ) and turn yaws by
|
|
293
|
+
// `-delta` to match three.js Y-rotation handedness (same convention as the
|
|
294
|
+
// single-item rotate handle in item/definition.ts). Endpoint nodes
|
|
295
|
+
// (walls/fences) have no yaw — swinging both endpoints around the pivot
|
|
296
|
+
// rotates them rigidly; their curveOffset sagitta is rotation-invariant, so
|
|
297
|
+
// arcs are preserved. Linked neighbours' shared endpoints land exactly on the
|
|
298
|
+
// selected wall's rotated endpoint (rot is deterministic), keeping junctions
|
|
299
|
+
// welded while the far end stays put.
|
|
300
|
+
export function rotateGroupPatches(
|
|
301
|
+
starts: ParticipantStart[],
|
|
302
|
+
links: LinkedNeighbor[],
|
|
303
|
+
center: { x: number; z: number },
|
|
304
|
+
delta: number,
|
|
305
|
+
): GroupPatch[] {
|
|
306
|
+
const cos = Math.cos(delta)
|
|
307
|
+
const sin = Math.sin(delta)
|
|
308
|
+
const rot = (x: number, z: number): Vec2 => {
|
|
309
|
+
const dx = x - center.x
|
|
310
|
+
const dz = z - center.z
|
|
311
|
+
return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
|
|
312
|
+
}
|
|
313
|
+
const patches: GroupPatch[] = []
|
|
314
|
+
for (const s of starts) {
|
|
315
|
+
if (s.kind === 'endpoint') {
|
|
316
|
+
patches.push([s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }])
|
|
317
|
+
} else if (s.kind === 'polygon') {
|
|
318
|
+
const patch: Record<string, unknown> = { polygon: s.polygon.map(([x, z]) => rot(x, z)) }
|
|
319
|
+
if (s.holes) patch.holes = s.holes.map((hole) => hole.map(([x, z]) => rot(x, z)))
|
|
320
|
+
patches.push([s.id, patch])
|
|
321
|
+
} else {
|
|
322
|
+
const [px, pz] = rot(s.position[0], s.position[2])
|
|
323
|
+
const position: Vec3 = [px, s.position[1], pz]
|
|
324
|
+
const rotation =
|
|
325
|
+
s.kind === 'vec3'
|
|
326
|
+
? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
|
|
327
|
+
: s.rotation - delta
|
|
328
|
+
patches.push([s.id, { position, rotation }])
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const l of links) {
|
|
332
|
+
patches.push([
|
|
333
|
+
l.id,
|
|
334
|
+
{
|
|
335
|
+
start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
|
|
336
|
+
end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
|
|
337
|
+
},
|
|
338
|
+
])
|
|
339
|
+
}
|
|
340
|
+
return patches
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Rotate the SNAPSHOTS themselves (same math as `rotateGroupPatches`, but
|
|
344
|
+
// producing new snapshot shapes instead of node patches). Lets an in-flight
|
|
345
|
+
// group move re-seed its rest state after a mid-drag R/T: subsequent
|
|
346
|
+
// translate patches then place the rotated layout at the live delta.
|
|
347
|
+
export function rotateGroupSnapshots(
|
|
348
|
+
starts: ParticipantStart[],
|
|
349
|
+
links: LinkedNeighbor[],
|
|
350
|
+
center: { x: number; z: number },
|
|
351
|
+
delta: number,
|
|
352
|
+
): { starts: ParticipantStart[]; links: LinkedNeighbor[] } {
|
|
353
|
+
const cos = Math.cos(delta)
|
|
354
|
+
const sin = Math.sin(delta)
|
|
355
|
+
const rot = (x: number, z: number): Vec2 => {
|
|
356
|
+
const dx = x - center.x
|
|
357
|
+
const dz = z - center.z
|
|
358
|
+
return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
|
|
359
|
+
}
|
|
360
|
+
const rotatedStarts = starts.map((s): ParticipantStart => {
|
|
361
|
+
if (s.kind === 'endpoint') {
|
|
362
|
+
return { ...s, start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }
|
|
363
|
+
}
|
|
364
|
+
if (s.kind === 'polygon') {
|
|
365
|
+
return {
|
|
366
|
+
...s,
|
|
367
|
+
polygon: s.polygon.map(([x, z]) => rot(x, z)),
|
|
368
|
+
holes: s.holes ? s.holes.map((hole) => hole.map(([x, z]) => rot(x, z))) : null,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const [px, pz] = rot(s.position[0], s.position[2])
|
|
372
|
+
const position: Vec3 = [px, s.position[1], pz]
|
|
373
|
+
if (s.kind === 'vec3') {
|
|
374
|
+
return { ...s, position, rotation: [s.rotation[0], s.rotation[1] - delta, s.rotation[2]] }
|
|
375
|
+
}
|
|
376
|
+
return { ...s, position, rotation: s.rotation - delta }
|
|
377
|
+
})
|
|
378
|
+
// Only the welded (linked) endpoints follow the rotation; the far ends
|
|
379
|
+
// stay put, exactly like the per-tick patch path.
|
|
380
|
+
const rotatedLinks = links.map(
|
|
381
|
+
(l): LinkedNeighbor => ({
|
|
382
|
+
...l,
|
|
383
|
+
start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
|
|
384
|
+
end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
|
|
385
|
+
}),
|
|
386
|
+
)
|
|
387
|
+
return { starts: rotatedStarts, links: rotatedLinks }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Level-frame XZ extents of the participant DATA — the mesh-free sibling of
|
|
391
|
+
// `computeGroupBox`, used when meshes aren't mounted yet and to re-seed
|
|
392
|
+
// alignment anchors after a mid-drag rotation.
|
|
393
|
+
export function participantExtents(
|
|
394
|
+
starts: ParticipantStart[],
|
|
395
|
+
): { minX: number; minZ: number; maxX: number; maxZ: number } | null {
|
|
396
|
+
let minX = Number.POSITIVE_INFINITY
|
|
397
|
+
let minZ = Number.POSITIVE_INFINITY
|
|
398
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
399
|
+
let maxZ = Number.NEGATIVE_INFINITY
|
|
400
|
+
const reach = (x: number, z: number) => {
|
|
401
|
+
minX = Math.min(minX, x)
|
|
402
|
+
minZ = Math.min(minZ, z)
|
|
403
|
+
maxX = Math.max(maxX, x)
|
|
404
|
+
maxZ = Math.max(maxZ, z)
|
|
405
|
+
}
|
|
406
|
+
for (const s of starts) {
|
|
407
|
+
if (s.kind === 'endpoint') {
|
|
408
|
+
reach(s.start[0], s.start[1])
|
|
409
|
+
reach(s.end[0], s.end[1])
|
|
410
|
+
} else if (s.kind === 'polygon') {
|
|
411
|
+
for (const [x, z] of s.polygon) {
|
|
412
|
+
reach(x, z)
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
reach(s.position[0], s.position[2])
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!Number.isFinite(minX)) return null
|
|
419
|
+
return { minX, minZ, maxX, maxZ }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Rigid group slide: shift every participant (and each linked neighbour's
|
|
423
|
+
// shared endpoint) by the same level-frame XZ delta. Y and rotations untouched.
|
|
424
|
+
export function translateGroupPatches(
|
|
425
|
+
starts: ParticipantStart[],
|
|
426
|
+
links: LinkedNeighbor[],
|
|
427
|
+
dx: number,
|
|
428
|
+
dz: number,
|
|
429
|
+
): GroupPatch[] {
|
|
430
|
+
const patches: GroupPatch[] = []
|
|
431
|
+
const shift = ([x, z]: Vec2): Vec2 => [x + dx, z + dz]
|
|
432
|
+
for (const s of starts) {
|
|
433
|
+
if (s.kind === 'endpoint') {
|
|
434
|
+
patches.push([s.id, { start: shift(s.start), end: shift(s.end) }])
|
|
435
|
+
} else if (s.kind === 'polygon') {
|
|
436
|
+
const patch: Record<string, unknown> = { polygon: s.polygon.map(shift) }
|
|
437
|
+
if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift))
|
|
438
|
+
patches.push([s.id, patch])
|
|
439
|
+
} else {
|
|
440
|
+
patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }])
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
for (const l of links) {
|
|
444
|
+
patches.push([
|
|
445
|
+
l.id,
|
|
446
|
+
{
|
|
447
|
+
start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
|
|
448
|
+
end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
|
|
449
|
+
},
|
|
450
|
+
])
|
|
451
|
+
}
|
|
452
|
+
return patches
|
|
453
|
+
}
|
|
454
|
+
|
|
222
455
|
// Frozen world matrix of the level group + its inverse. A node's placement
|
|
223
456
|
// (`position` / `start` / `end`) is stored in its parent level's frame, but the
|
|
224
457
|
// gizmos raycast the ground plane in WORLD space. When the building is rotated
|
|
@@ -12,12 +12,27 @@ import {
|
|
|
12
12
|
DoubleSide,
|
|
13
13
|
ExtrudeGeometry,
|
|
14
14
|
type Group,
|
|
15
|
+
type Intersection,
|
|
16
|
+
Mesh,
|
|
17
|
+
type Raycaster,
|
|
15
18
|
Shape,
|
|
16
19
|
TorusGeometry,
|
|
17
20
|
} from 'three'
|
|
18
21
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
19
22
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
20
23
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
24
|
+
import useEditor from '../../../store/use-editor'
|
|
25
|
+
|
|
26
|
+
// While a press-drag move is in flight (`placementDragMode`), the move tool
|
|
27
|
+
// owns the pointer and the handle rig rides the moving node — so a handle hit
|
|
28
|
+
// area would sit under the cursor and starve the tool's surface raycast
|
|
29
|
+
// (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
|
|
30
|
+
// Make every handle hit area inert for the duration; the indicator mesh still
|
|
31
|
+
// renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
|
|
32
|
+
function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
|
|
33
|
+
if (useEditor.getState().placementDragMode) return
|
|
34
|
+
Mesh.prototype.raycast.call(this, raycaster, intersects)
|
|
35
|
+
}
|
|
21
36
|
|
|
22
37
|
export const ARROW_SCALE = 0.65
|
|
23
38
|
export const ARROW_COLOR = '#8381ed'
|
|
@@ -36,6 +51,13 @@ const CHEVRON_DEPTH = 0.08
|
|
|
36
51
|
const CHEVRON_BEVEL_THICKNESS = 0.035
|
|
37
52
|
const CHEVRON_BEVEL_SIZE = 0.03
|
|
38
53
|
const CHEVRON_BEVEL_SEGMENTS = 10
|
|
54
|
+
// Slimmer extrude profile matching the legacy wall side handles
|
|
55
|
+
// (`wall-move-side-handles.tsx`) — opt-in via the `thin` prop so the chunkier
|
|
56
|
+
// default is preserved for every other handle that uses the shared chevron.
|
|
57
|
+
const CHEVRON_THIN_DEPTH = 0.045
|
|
58
|
+
const CHEVRON_THIN_BEVEL_THICKNESS = 0.018
|
|
59
|
+
const CHEVRON_THIN_BEVEL_SIZE = 0.02
|
|
60
|
+
const CHEVRON_THIN_BEVEL_SEGMENTS = 8
|
|
39
61
|
const MOVE_CROSS_HALF_LENGTH = 0.36
|
|
40
62
|
const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03
|
|
41
63
|
const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12
|
|
@@ -49,7 +71,7 @@ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3
|
|
|
49
71
|
const ROTATE_RIBBON_HALF_WIDTH = 0.02
|
|
50
72
|
const ROTATE_HEAD_HALF_WIDTH = 0.045
|
|
51
73
|
const TRACKER_CUBE_SIZE = 0.16
|
|
52
|
-
export const CORNER_HEX_RADIUS = 0.
|
|
74
|
+
export const CORNER_HEX_RADIUS = 0.11
|
|
53
75
|
|
|
54
76
|
export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker'
|
|
55
77
|
export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
|
|
@@ -75,6 +97,10 @@ export type HandleArrowProps = {
|
|
|
75
97
|
indicatorRotation?: readonly [number, number, number]
|
|
76
98
|
onPointerEnter?: PointerHandler
|
|
77
99
|
onPointerLeave?: PointerHandler
|
|
100
|
+
// Extrude the slimmer wall-handle chevron profile (chevron shape only).
|
|
101
|
+
thin?: boolean
|
|
102
|
+
// Render the corner-picker disc as a smooth circle instead of a hexagon.
|
|
103
|
+
round?: boolean
|
|
78
104
|
}
|
|
79
105
|
|
|
80
106
|
function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
|
|
@@ -164,8 +190,9 @@ export function createRotateArrowHandleGeometry() {
|
|
|
164
190
|
|
|
165
191
|
// Reused chevron+shaft silhouette. The chevron points along +X by default;
|
|
166
192
|
// callers rotate it around Y for Z-axis handles and into a vertical frame for
|
|
167
|
-
// Y-axis handles.
|
|
168
|
-
export function createArrowHandleGeometry() {
|
|
193
|
+
// Y-axis handles. `thin` extrudes the slimmer wall-handle profile.
|
|
194
|
+
export function createArrowHandleGeometry(thin = false) {
|
|
195
|
+
const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH
|
|
169
196
|
const shape = new Shape()
|
|
170
197
|
shape.moveTo(CHEVRON_MAX_X, 0)
|
|
171
198
|
shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH)
|
|
@@ -176,16 +203,16 @@ export function createArrowHandleGeometry() {
|
|
|
176
203
|
shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH)
|
|
177
204
|
shape.lineTo(CHEVRON_MAX_X, 0)
|
|
178
205
|
const geometry = new ExtrudeGeometry(shape, {
|
|
179
|
-
depth
|
|
206
|
+
depth,
|
|
180
207
|
bevelEnabled: true,
|
|
181
|
-
bevelThickness: CHEVRON_BEVEL_THICKNESS,
|
|
182
|
-
bevelSize: CHEVRON_BEVEL_SIZE,
|
|
208
|
+
bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS,
|
|
209
|
+
bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE,
|
|
183
210
|
bevelOffset: 0,
|
|
184
|
-
bevelSegments: CHEVRON_BEVEL_SEGMENTS,
|
|
211
|
+
bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS,
|
|
185
212
|
curveSegments: 16,
|
|
186
213
|
steps: 1,
|
|
187
214
|
})
|
|
188
|
-
geometry.translate(0, 0, -
|
|
215
|
+
geometry.translate(0, 0, -depth / 2)
|
|
189
216
|
geometry.rotateX(-Math.PI / 2)
|
|
190
217
|
geometry.computeVertexNormals()
|
|
191
218
|
geometry.computeBoundingSphere()
|
|
@@ -254,15 +281,27 @@ export function createArrowHitAreaGeometry() {
|
|
|
254
281
|
return geometry
|
|
255
282
|
}
|
|
256
283
|
|
|
284
|
+
// The move cross is a plus, not a disk. A disk-shaped hit area fills the four
|
|
285
|
+
// corner gaps between the arms, so a neighbouring node sitting next to the
|
|
286
|
+
// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the
|
|
287
|
+
// invisible grip and can't be picked. Wrap the visible arms instead: two flat
|
|
288
|
+
// arm boxes (length/width + margin) merged into a plus, leaving the corners
|
|
289
|
+
// empty so co-located neighbours stay selectable while the grip stays grabbable.
|
|
257
290
|
function createMoveCrossHitAreaGeometry() {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
|
|
291
|
+
const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
|
|
292
|
+
const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2
|
|
293
|
+
const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth)
|
|
294
|
+
const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength)
|
|
295
|
+
const merged = mergeGeometries([armX, armZ], false)
|
|
296
|
+
if (!merged) {
|
|
297
|
+
armZ.dispose()
|
|
298
|
+
armX.computeBoundingSphere()
|
|
299
|
+
return armX
|
|
300
|
+
}
|
|
301
|
+
armX.dispose()
|
|
302
|
+
armZ.dispose()
|
|
303
|
+
merged.computeBoundingSphere()
|
|
304
|
+
return merged
|
|
266
305
|
}
|
|
267
306
|
|
|
268
307
|
export function createRotateArrowHitAreaGeometry() {
|
|
@@ -299,8 +338,12 @@ export function createEndpointHitAreaGeometry(radius: number) {
|
|
|
299
338
|
return geometry
|
|
300
339
|
}
|
|
301
340
|
|
|
302
|
-
|
|
303
|
-
|
|
341
|
+
// Hexagon (6 segments) by default; a smooth circle (32 segments) when `round`.
|
|
342
|
+
const CORNER_DISC_SEGMENTS = 6
|
|
343
|
+
const CORNER_DISC_ROUND_SEGMENTS = 32
|
|
344
|
+
|
|
345
|
+
function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round = false) {
|
|
346
|
+
if (shape === 'chevron') return createArrowHandleGeometry(thin)
|
|
304
347
|
if (shape === 'cross') return createMoveCrossHandleGeometry()
|
|
305
348
|
if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
|
|
306
349
|
if (shape === 'tracker') {
|
|
@@ -308,17 +351,23 @@ function createHandleArrowGeometry(shape: HandleArrowShape) {
|
|
|
308
351
|
geometry.computeBoundingSphere()
|
|
309
352
|
return geometry
|
|
310
353
|
}
|
|
311
|
-
const geometry = new CircleGeometry(
|
|
354
|
+
const geometry = new CircleGeometry(
|
|
355
|
+
CORNER_HEX_RADIUS,
|
|
356
|
+
round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
|
|
357
|
+
)
|
|
312
358
|
geometry.computeBoundingSphere()
|
|
313
359
|
return geometry
|
|
314
360
|
}
|
|
315
361
|
|
|
316
|
-
function createHandleArrowHitGeometry(shape: HandleArrowShape) {
|
|
362
|
+
function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) {
|
|
317
363
|
if (shape === 'chevron') return createArrowHitAreaGeometry()
|
|
318
364
|
if (shape === 'cross') return createMoveCrossHitAreaGeometry()
|
|
319
365
|
if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry()
|
|
320
366
|
if (shape === 'tracker') return createTrackerHitAreaGeometry()
|
|
321
|
-
const geometry = new CircleGeometry(
|
|
367
|
+
const geometry = new CircleGeometry(
|
|
368
|
+
CORNER_HEX_RADIUS,
|
|
369
|
+
round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
|
|
370
|
+
)
|
|
322
371
|
geometry.computeBoundingSphere()
|
|
323
372
|
return geometry
|
|
324
373
|
}
|
|
@@ -382,6 +431,7 @@ export function InvisibleHandleHitArea({
|
|
|
382
431
|
onPointerDown={onPointerDown}
|
|
383
432
|
onPointerEnter={onPointerEnter}
|
|
384
433
|
onPointerLeave={onPointerLeave}
|
|
434
|
+
raycast={hitAreaRaycast}
|
|
385
435
|
renderOrder={HIT_AREA_RENDER_ORDER}
|
|
386
436
|
scale={scale}
|
|
387
437
|
/>
|
|
@@ -443,10 +493,18 @@ export function HandleArrow({
|
|
|
443
493
|
onPointerDown,
|
|
444
494
|
onPointerEnter,
|
|
445
495
|
onPointerLeave,
|
|
496
|
+
thin = false,
|
|
497
|
+
round = false,
|
|
446
498
|
}: HandleArrowProps) {
|
|
447
499
|
const visualShape = normalizeHandleArrowShape(shape, cursor)
|
|
448
|
-
const geometry = useMemo(
|
|
449
|
-
|
|
500
|
+
const geometry = useMemo(
|
|
501
|
+
() => createHandleArrowGeometry(visualShape, thin, round),
|
|
502
|
+
[visualShape, thin, round],
|
|
503
|
+
)
|
|
504
|
+
const hitGeometry = useMemo(
|
|
505
|
+
() => createHandleArrowHitGeometry(visualShape, round),
|
|
506
|
+
[visualShape, round],
|
|
507
|
+
)
|
|
450
508
|
const indicatorMaterial = useHandleArrowMaterial(visualShape)
|
|
451
509
|
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
452
510
|
const rootRef = useRef<Group>(null)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
runAsSingleSceneHistoryStep,
|
|
6
|
+
useScene,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
import { commitHandleDragPatch } from './handle-drag-history'
|
|
9
|
+
|
|
10
|
+
type RafFn = (callback: (time: number) => void) => number
|
|
11
|
+
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => {
|
|
12
|
+
callback(0)
|
|
13
|
+
return 0
|
|
14
|
+
}
|
|
15
|
+
;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
|
|
16
|
+
|
|
17
|
+
const NODE_ID = 'shelf_handle-drag-history' as AnyNodeId
|
|
18
|
+
const COMPANION_NODE_ID = 'shelf_handle-drag-history-companion' as AnyNodeId
|
|
19
|
+
|
|
20
|
+
function shelf(depth: number, id = NODE_ID): AnyNode {
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
type: 'shelf',
|
|
24
|
+
object: 'node',
|
|
25
|
+
parentId: null,
|
|
26
|
+
visible: true,
|
|
27
|
+
metadata: {},
|
|
28
|
+
children: [],
|
|
29
|
+
position: [0, 0, 0],
|
|
30
|
+
rotation: [0, 0, 0],
|
|
31
|
+
width: 1,
|
|
32
|
+
depth,
|
|
33
|
+
thickness: 0.04,
|
|
34
|
+
height: 0.9,
|
|
35
|
+
style: 'wall-shelf',
|
|
36
|
+
rows: 1,
|
|
37
|
+
columns: 1,
|
|
38
|
+
withBack: false,
|
|
39
|
+
withSides: true,
|
|
40
|
+
withBottom: false,
|
|
41
|
+
bracketStyle: 'minimal',
|
|
42
|
+
} as AnyNode
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('commitHandleDragPatch', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
|
|
48
|
+
useScene.temporal.getState().clear()
|
|
49
|
+
useScene.temporal.getState().resume()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('records the final patch as one undo step after preview history is resumed', () => {
|
|
53
|
+
useScene.getState().createNode(shelf(0.3))
|
|
54
|
+
const pastCount = useScene.temporal.getState().pastStates.length
|
|
55
|
+
useScene.temporal.getState().pause()
|
|
56
|
+
|
|
57
|
+
commitHandleDragPatch({
|
|
58
|
+
patch: { depth: 0.7 },
|
|
59
|
+
resumeHistory: () => useScene.temporal.getState().resume(),
|
|
60
|
+
runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
|
|
61
|
+
commit: (patch) => useScene.getState().updateNode(NODE_ID, patch),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(pastCount + 1)
|
|
65
|
+
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
|
|
66
|
+
|
|
67
|
+
useScene.temporal.getState().undo()
|
|
68
|
+
expect(useScene.getState().nodes[NODE_ID]).toBeDefined()
|
|
69
|
+
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('records a composite multi-node commit as one undo step', () => {
|
|
73
|
+
useScene.getState().createNode(shelf(0.3))
|
|
74
|
+
useScene.getState().createNode(shelf(0.4, COMPANION_NODE_ID))
|
|
75
|
+
useScene.temporal.getState().clear()
|
|
76
|
+
useScene.temporal.getState().pause()
|
|
77
|
+
|
|
78
|
+
commitHandleDragPatch({
|
|
79
|
+
patch: { selectedDepth: 0.7, companionDepth: 0.2 },
|
|
80
|
+
resumeHistory: () => useScene.temporal.getState().resume(),
|
|
81
|
+
runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
|
|
82
|
+
commit: ({ selectedDepth, companionDepth }) => {
|
|
83
|
+
useScene.getState().updateNode(NODE_ID, { depth: selectedDepth })
|
|
84
|
+
useScene.getState().updateNode(COMPANION_NODE_ID, { depth: companionDepth })
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
89
|
+
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
|
|
90
|
+
expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.2)
|
|
91
|
+
|
|
92
|
+
useScene.temporal.getState().undo()
|
|
93
|
+
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
|
|
94
|
+
expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.4)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function commitHandleDragPatch<T>({
|
|
2
|
+
commit,
|
|
3
|
+
patch,
|
|
4
|
+
resumeHistory,
|
|
5
|
+
runAsSingleHistoryStep,
|
|
6
|
+
}: {
|
|
7
|
+
commit: (patch: T) => void
|
|
8
|
+
patch: T
|
|
9
|
+
resumeHistory: () => void
|
|
10
|
+
runAsSingleHistoryStep: (run: () => void) => void
|
|
11
|
+
}) {
|
|
12
|
+
resumeHistory()
|
|
13
|
+
runAsSingleHistoryStep(() => commit(patch))
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import type { AnyNodeId } from '@pascal-app/core'
|
|
3
|
+
import { replacePreviewOverrideIds } from './preview-overrides'
|
|
4
|
+
|
|
5
|
+
const FIRST_ID = 'cabinet_first' as AnyNodeId
|
|
6
|
+
const SECOND_ID = 'cabinet_second' as AnyNodeId
|
|
7
|
+
const THIRD_ID = 'cabinet_third' as AnyNodeId
|
|
8
|
+
|
|
9
|
+
describe('replacePreviewOverrideIds', () => {
|
|
10
|
+
test('clears companion overrides that leave the active preview', () => {
|
|
11
|
+
const clear = mock(() => {})
|
|
12
|
+
|
|
13
|
+
const nextIds = replacePreviewOverrideIds(
|
|
14
|
+
new Set([FIRST_ID, SECOND_ID]),
|
|
15
|
+
[
|
|
16
|
+
[SECOND_ID, { width: 0.7 }],
|
|
17
|
+
[THIRD_ID, { width: 0.5 }],
|
|
18
|
+
],
|
|
19
|
+
clear,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(clear).toHaveBeenCalledTimes(1)
|
|
23
|
+
expect(clear).toHaveBeenCalledWith(FIRST_ID)
|
|
24
|
+
expect(nextIds).toEqual(new Set([SECOND_ID, THIRD_ID]))
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function replacePreviewOverrideIds(
|
|
4
|
+
activeIds: ReadonlySet<AnyNodeId>,
|
|
5
|
+
entries: ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>,
|
|
6
|
+
clear: (id: AnyNodeId) => void,
|
|
7
|
+
): Set<AnyNodeId> {
|
|
8
|
+
const nextIds = new Set(entries.map(([id]) => id))
|
|
9
|
+
for (const id of activeIds) {
|
|
10
|
+
if (!nextIds.has(id)) clear(id)
|
|
11
|
+
}
|
|
12
|
+
return nextIds
|
|
13
|
+
}
|