@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
|
@@ -3,12 +3,21 @@ import { SCENE_LAYER } from '@pascal-app/viewer'
|
|
|
3
3
|
import { useGLTF } from '@react-three/drei/core/Gltf'
|
|
4
4
|
import { useFrame } from '@react-three/fiber'
|
|
5
5
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
Color,
|
|
8
|
+
CylinderGeometry,
|
|
9
|
+
DoubleSide,
|
|
10
|
+
type Group,
|
|
11
|
+
type Mesh,
|
|
12
|
+
type Object3D,
|
|
13
|
+
RingGeometry,
|
|
14
|
+
} from 'three'
|
|
7
15
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
8
16
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
9
17
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
10
18
|
import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary'
|
|
11
19
|
import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor'
|
|
20
|
+
import useInteractionScope from '../../../store/use-interaction-scope'
|
|
12
21
|
import {
|
|
13
22
|
ARROW_COLOR,
|
|
14
23
|
ARROW_HOVER_COLOR,
|
|
@@ -38,6 +47,7 @@ const SITE_FLAG_HALO_COLOR = '#6366f1'
|
|
|
38
47
|
|
|
39
48
|
type TintableMaterial = {
|
|
40
49
|
color?: Color
|
|
50
|
+
depthTest: boolean
|
|
41
51
|
depthWrite: boolean
|
|
42
52
|
opacity: number
|
|
43
53
|
needsUpdate: boolean
|
|
@@ -69,10 +79,9 @@ function SiteFlagModel({
|
|
|
69
79
|
mesh.frustumCulled = false
|
|
70
80
|
mesh.raycast = NO_RAYCAST
|
|
71
81
|
mesh.receiveShadow = false
|
|
72
|
-
mesh.renderOrder = 1010
|
|
73
82
|
mesh.material = new MeshBasicNodeMaterial({
|
|
74
83
|
color: new Color(ARROW_COLOR),
|
|
75
|
-
depthTest:
|
|
84
|
+
depthTest: true,
|
|
76
85
|
depthWrite: opacity >= 0.999,
|
|
77
86
|
opacity,
|
|
78
87
|
transparent: opacity < 0.999,
|
|
@@ -93,6 +102,7 @@ function SiteFlagModel({
|
|
|
93
102
|
|
|
94
103
|
for (const material of materials as Array<MeshBasicNodeMaterial & TintableMaterial>) {
|
|
95
104
|
material.color?.copy(color)
|
|
105
|
+
material.depthTest = true
|
|
96
106
|
material.opacity = opacity
|
|
97
107
|
material.transparent = opacity < 0.999
|
|
98
108
|
material.depthWrite = opacity >= 0.999
|
|
@@ -217,11 +227,23 @@ function SiteFlagFallback({
|
|
|
217
227
|
<group position={[0, SITE_FLAG_BASE_Y + (active ? SITE_FLAG_ACTIVE_LIFT : 0), 0]}>
|
|
218
228
|
<mesh layers={SCENE_LAYER} position={[0, 0.16, 0]} raycast={NO_RAYCAST}>
|
|
219
229
|
<cylinderGeometry args={[0.11, 0.16, 0.32, 24]} />
|
|
220
|
-
<meshBasicMaterial
|
|
230
|
+
<meshBasicMaterial
|
|
231
|
+
color={color}
|
|
232
|
+
depthTest
|
|
233
|
+
depthWrite={opacity >= 0.999}
|
|
234
|
+
opacity={opacity}
|
|
235
|
+
transparent={opacity < 0.999}
|
|
236
|
+
/>
|
|
221
237
|
</mesh>
|
|
222
238
|
<mesh layers={SCENE_LAYER} position={[0, 0.34, 0]} raycast={NO_RAYCAST}>
|
|
223
239
|
<cylinderGeometry args={[0.04, 0.11, 0.14, 24]} />
|
|
224
|
-
<meshBasicMaterial
|
|
240
|
+
<meshBasicMaterial
|
|
241
|
+
color={color}
|
|
242
|
+
depthTest
|
|
243
|
+
depthWrite={opacity >= 0.999}
|
|
244
|
+
opacity={opacity}
|
|
245
|
+
transparent={opacity < 0.999}
|
|
246
|
+
/>
|
|
225
247
|
</mesh>
|
|
226
248
|
</group>
|
|
227
249
|
)
|
|
@@ -355,14 +377,20 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|
|
355
377
|
|
|
356
378
|
if (!siteId) return
|
|
357
379
|
|
|
358
|
-
const editor = useEditor.getState()
|
|
359
380
|
if (isDragging) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
381
|
+
useInteractionScope
|
|
382
|
+
.getState()
|
|
383
|
+
.begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
|
|
384
|
+
} else {
|
|
385
|
+
const scope = useInteractionScope.getState().scope
|
|
386
|
+
const activeHandleDrag =
|
|
387
|
+
scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
|
|
388
|
+
if (
|
|
389
|
+
activeHandleDrag?.nodeId === siteId &&
|
|
390
|
+
activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
|
391
|
+
) {
|
|
392
|
+
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
|
393
|
+
}
|
|
366
394
|
}
|
|
367
395
|
|
|
368
396
|
if (!isDragging) {
|
|
@@ -375,12 +403,14 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|
|
375
403
|
useEffect(
|
|
376
404
|
() => () => {
|
|
377
405
|
if (!siteId) return
|
|
378
|
-
const
|
|
406
|
+
const scope = useInteractionScope.getState().scope
|
|
407
|
+
const activeHandleDrag =
|
|
408
|
+
scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
|
|
379
409
|
if (
|
|
380
|
-
|
|
381
|
-
|
|
410
|
+
activeHandleDrag?.nodeId === siteId &&
|
|
411
|
+
activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
|
382
412
|
) {
|
|
383
|
-
|
|
413
|
+
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
|
384
414
|
}
|
|
385
415
|
useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
|
|
386
416
|
isDraggingSiteBoundaryRef.current = false
|
|
@@ -394,12 +424,35 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|
|
394
424
|
}
|
|
395
425
|
}, [isSiteEditing, siteId])
|
|
396
426
|
|
|
427
|
+
// The flag models render on SCENE_LAYER (scene-depth occlusion), so unlike
|
|
428
|
+
// EDITOR_LAYER affordances the thumbnail camera can't filter them — hide
|
|
429
|
+
// them around captures (preset/snapshot/auto-save thumbnails), same as
|
|
430
|
+
// `handle-arrow.tsx`.
|
|
431
|
+
const handlesRootRef = useRef<Group>(null)
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
const hideForCapture = () => {
|
|
434
|
+
if (handlesRootRef.current) handlesRootRef.current.visible = false
|
|
435
|
+
}
|
|
436
|
+
const restoreAfterCapture = () => {
|
|
437
|
+
if (handlesRootRef.current) handlesRootRef.current.visible = true
|
|
438
|
+
}
|
|
439
|
+
emitter.on('thumbnail:before-capture', hideForCapture)
|
|
440
|
+
emitter.on('thumbnail:after-capture', restoreAfterCapture)
|
|
441
|
+
return () => {
|
|
442
|
+
emitter.off('thumbnail:before-capture', hideForCapture)
|
|
443
|
+
emitter.off('thumbnail:after-capture', restoreAfterCapture)
|
|
444
|
+
}
|
|
445
|
+
}, [])
|
|
446
|
+
|
|
397
447
|
const activateSiteEditing = useCallback(() => {
|
|
398
448
|
isDraggingSiteBoundaryRef.current = true
|
|
399
449
|
setIsDraggingSiteBoundary(true)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
450
|
+
const editor = useEditor.getState()
|
|
451
|
+
// A boundary drag takes ownership away from the terrain brush. Going
|
|
452
|
+
// through setMode releases the sustained sculpting scope before the
|
|
453
|
+
// PolygonEditor begins its handle-drag scope.
|
|
454
|
+
if (editor.mode !== 'select') editor.setMode('select')
|
|
455
|
+
if (editor.phase !== 'site') editor.setPhase('site')
|
|
403
456
|
selectSiteFloorplanContext()
|
|
404
457
|
}, [])
|
|
405
458
|
|
|
@@ -411,10 +464,14 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|
|
411
464
|
}, [])
|
|
412
465
|
|
|
413
466
|
useEffect(() => {
|
|
414
|
-
|
|
467
|
+
// Terrain sculpting also lives in the site phase so the 3D flags stay
|
|
468
|
+
// available, but its ordinary terrain clicks belong to the brush. Only
|
|
469
|
+
// select mode treats an empty site-grid click as "leave site editing".
|
|
470
|
+
if (!isSiteEditing || mode !== 'select') return
|
|
415
471
|
|
|
416
472
|
const onGridClick = () => {
|
|
417
|
-
|
|
473
|
+
const editor = useEditor.getState()
|
|
474
|
+
if (editor.phase !== 'site' || editor.mode !== 'select') return
|
|
418
475
|
exitSiteEditing()
|
|
419
476
|
}
|
|
420
477
|
|
|
@@ -422,7 +479,7 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|
|
422
479
|
return () => {
|
|
423
480
|
emitter.off('grid:click', onGridClick)
|
|
424
481
|
}
|
|
425
|
-
}, [exitSiteEditing, isSiteEditing])
|
|
482
|
+
}, [exitSiteEditing, isSiteEditing, mode])
|
|
426
483
|
|
|
427
484
|
const renderSiteFlagVertex = useCallback(
|
|
428
485
|
({
|
|
@@ -470,25 +527,27 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|
|
470
527
|
if (!showSiteHandles) return null
|
|
471
528
|
|
|
472
529
|
return (
|
|
473
|
-
<
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
530
|
+
<group ref={handlesRootRef}>
|
|
531
|
+
<PolygonEditor
|
|
532
|
+
color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
|
|
533
|
+
minVertices={3}
|
|
534
|
+
onBeforeVertexDrag={activateSiteEditing}
|
|
535
|
+
onDragCommit={() => {
|
|
536
|
+
sfxEmitter.emit('sfx:item-place')
|
|
537
|
+
exitSiteEditing()
|
|
538
|
+
}}
|
|
539
|
+
onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
|
|
540
|
+
onDragStateChange={handleSiteBoundaryDragChange}
|
|
541
|
+
onMidpointHoverChange={setHoveredMidpoint}
|
|
542
|
+
onPolygonChange={handlePolygonChange}
|
|
543
|
+
onPolygonPreview={handlePolygonPreview}
|
|
544
|
+
onVertexHoverChange={setHoveredVertex}
|
|
545
|
+
polygon={displayPolygon}
|
|
546
|
+
renderMidpointHandle={renderSiteFlagMidpoint}
|
|
547
|
+
renderVertexHandle={renderSiteFlagVertex}
|
|
548
|
+
showBorderLine={isSiteBoundaryHighlighted}
|
|
549
|
+
showMidpointHandles={showSiteHandles}
|
|
550
|
+
/>
|
|
551
|
+
</group>
|
|
493
552
|
)
|
|
494
553
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type StrokePointerAction,
|
|
4
|
+
type StrokePointerEvent,
|
|
5
|
+
strokePointerAction,
|
|
6
|
+
} from './stroke-pointer-ownership'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A stroke carries uncommitted state, so every one of these has a silent failure
|
|
10
|
+
* mode rather than a visible one — which is why the matrix is asserted rather
|
|
11
|
+
* than left to the four call sites.
|
|
12
|
+
*/
|
|
13
|
+
describe('strokePointerAction', () => {
|
|
14
|
+
const OWNER = 1
|
|
15
|
+
const OTHER = 2
|
|
16
|
+
const act = (event: StrokePointerEvent, pointerId: number, ownerPointerId: number | null) =>
|
|
17
|
+
strokePointerAction({ event, pointerId, ownerPointerId })
|
|
18
|
+
|
|
19
|
+
describe('with no stroke in flight', () => {
|
|
20
|
+
test('a down starts one', () => {
|
|
21
|
+
expect(act('down', OWNER, null)).toBe('begin')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('every other event is ignored', () => {
|
|
25
|
+
// Notably `up`: the pointerup of a click that never began a stroke (over the
|
|
26
|
+
// eyedropper, or off the field) must not commit anything.
|
|
27
|
+
for (const event of ['move', 'up', 'cancel'] as StrokePointerEvent[]) {
|
|
28
|
+
expect(act(event, OWNER, null)).toBe('ignore')
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('the owning pointer drives the stroke', () => {
|
|
34
|
+
test('move paints, up commits', () => {
|
|
35
|
+
expect(act('move', OWNER, OWNER)).toBe('apply')
|
|
36
|
+
expect(act('up', OWNER, OWNER)).toBe('commit')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('cancel abandons where up commits', () => {
|
|
40
|
+
// The browser fires `cancel` when it takes the gesture away, so the user
|
|
41
|
+
// never finished the stroke. These two must not be the same handler — they
|
|
42
|
+
// were, and a cancelled drag persisted.
|
|
43
|
+
expect(act('cancel', OWNER, OWNER)).toBe('abandon')
|
|
44
|
+
expect(act('cancel', OWNER, OWNER)).not.toBe(act('up', OWNER, OWNER))
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('a second pointer', () => {
|
|
49
|
+
test('down abandons the stroke in flight', () => {
|
|
50
|
+
// Two fingers is a pinch-zoom. Abandoning restores the ground exactly,
|
|
51
|
+
// because nothing was committed yet.
|
|
52
|
+
expect(act('down', OTHER, OWNER)).toBe('abandon')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('never touches the owner’s stroke any other way', () => {
|
|
56
|
+
for (const event of ['move', 'up', 'cancel'] as StrokePointerEvent[]) {
|
|
57
|
+
expect(act(event, OTHER, OWNER)).toBe('ignore')
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('lifting it does not commit — the bug this replaced', () => {
|
|
62
|
+
// The old handler keyed off `strokeRef` alone, so a resting finger lifting
|
|
63
|
+
// committed a stroke the other finger was still drawing.
|
|
64
|
+
expect(act('up', OTHER, OWNER)).not.toBe('commit')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('only a down can begin, and only an owning up can commit', () => {
|
|
69
|
+
// Swept over the whole matrix rather than spot-checked: these are the two
|
|
70
|
+
// actions that write to the scene, so anything else reaching them is a bug.
|
|
71
|
+
const events: StrokePointerEvent[] = ['down', 'move', 'up', 'cancel']
|
|
72
|
+
const owners: Array<number | null> = [null, OWNER]
|
|
73
|
+
const seen: Array<[StrokePointerEvent, number, number | null, StrokePointerAction]> = []
|
|
74
|
+
for (const event of events) {
|
|
75
|
+
for (const pointerId of [OWNER, OTHER]) {
|
|
76
|
+
for (const ownerPointerId of owners) {
|
|
77
|
+
seen.push([event, pointerId, ownerPointerId, act(event, pointerId, ownerPointerId)])
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
expect(seen.filter(([, , , action]) => action === 'begin').map(([event]) => event)).toEqual([
|
|
82
|
+
'down',
|
|
83
|
+
'down',
|
|
84
|
+
])
|
|
85
|
+
expect(
|
|
86
|
+
seen
|
|
87
|
+
.filter(([, , , action]) => action === 'commit')
|
|
88
|
+
.map(([event, pointerId, ownerPointerId]) => [event, pointerId === ownerPointerId]),
|
|
89
|
+
).toEqual([['up', true]])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pointer a terrain stroke belongs to, and what every other one does.
|
|
3
|
+
*
|
|
4
|
+
* A stroke is a sustained drag with uncommitted state, so "who owns the pointer"
|
|
5
|
+
* is a real decision rather than plumbing — and each wrong answer fails silently
|
|
6
|
+
* in its own way. The tool used to have no answer at all: every branch below fell
|
|
7
|
+
* through to the owning-pointer path.
|
|
8
|
+
*
|
|
9
|
+
* Extracted as a pure function because the interesting part is the *matrix*, which
|
|
10
|
+
* cannot be asserted through the component — reproducing it needs a WebGL canvas,
|
|
11
|
+
* a camera, and synthetic multi-touch. The tool keeps the DOM plumbing.
|
|
12
|
+
*/
|
|
13
|
+
export type StrokePointerAction =
|
|
14
|
+
/** Start a stroke owned by this pointer. */
|
|
15
|
+
| 'begin'
|
|
16
|
+
/** Route the event into the active stroke. */
|
|
17
|
+
| 'apply'
|
|
18
|
+
/** Drop the stroke, restoring the pre-stroke ground. */
|
|
19
|
+
| 'abandon'
|
|
20
|
+
/** Finish and persist the stroke. */
|
|
21
|
+
| 'commit'
|
|
22
|
+
/** Not our pointer, or nothing in flight. */
|
|
23
|
+
| 'ignore'
|
|
24
|
+
|
|
25
|
+
export type StrokePointerEvent = 'down' | 'move' | 'up' | 'cancel'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `ownerPointerId` is `null` when no stroke is in flight.
|
|
29
|
+
*
|
|
30
|
+
* The rules, in the order they matter:
|
|
31
|
+
*
|
|
32
|
+
* - **A second `down` abandons.** Two fingers on the glass is a pinch-zoom, and by
|
|
33
|
+
* then the first finger has already deposited a dab; abandoning is what makes
|
|
34
|
+
* "pinch to look closer" leave no accidental bump. Falling through was the worst
|
|
35
|
+
* of the options: it orphaned an uncommitted stroke whose dabs were already
|
|
36
|
+
* baked into the live field's snapshot, so they survived on screen while being
|
|
37
|
+
* unreachable by undo.
|
|
38
|
+
* - **A non-owning `up` is ignored, not a commit.** Lifting a resting second finger
|
|
39
|
+
* would otherwise persist a stroke the other finger is still drawing.
|
|
40
|
+
* - **`cancel` abandons where `up` commits.** The browser fires `cancel` when it
|
|
41
|
+
* takes the gesture away (OS edge swipe, palm rejection), meaning the user never
|
|
42
|
+
* finished — committing there persists a half-drag nobody released.
|
|
43
|
+
*/
|
|
44
|
+
export function strokePointerAction(args: {
|
|
45
|
+
event: StrokePointerEvent
|
|
46
|
+
pointerId: number
|
|
47
|
+
ownerPointerId: number | null
|
|
48
|
+
}): StrokePointerAction {
|
|
49
|
+
const { event, pointerId, ownerPointerId } = args
|
|
50
|
+
|
|
51
|
+
if (event === 'down') {
|
|
52
|
+
return ownerPointerId === null ? 'begin' : 'abandon'
|
|
53
|
+
}
|
|
54
|
+
if (ownerPointerId === null || pointerId !== ownerPointerId) return 'ignore'
|
|
55
|
+
if (event === 'move') return 'apply'
|
|
56
|
+
return event === 'cancel' ? 'abandon' : 'commit'
|
|
57
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
minBrushRadius,
|
|
5
|
+
raycastTerrain,
|
|
6
|
+
type SiteNode,
|
|
7
|
+
surfaceHeightAt,
|
|
8
|
+
type TerrainField,
|
|
9
|
+
terrainFieldOf,
|
|
10
|
+
useLiveTerrain,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
13
|
+
import { Html } from '@react-three/drei'
|
|
14
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
15
|
+
import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
|
|
16
|
+
import {
|
|
17
|
+
BufferGeometry,
|
|
18
|
+
Float32BufferAttribute,
|
|
19
|
+
type Group,
|
|
20
|
+
type Line,
|
|
21
|
+
Raycaster,
|
|
22
|
+
Vector2,
|
|
23
|
+
} from 'three'
|
|
24
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
25
|
+
import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt'
|
|
26
|
+
import { brushRingColor } from '../../../lib/terrain-verb-color'
|
|
27
|
+
import useEditor from '../../../store/use-editor'
|
|
28
|
+
import { formatMeasurement } from '../../editor/measurement-pill'
|
|
29
|
+
|
|
30
|
+
/** Segments in the cursor ring. 64 reads as a circle at any usable brush size. */
|
|
31
|
+
const RING_SEGMENTS = 64
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lift above the surface, so the ring is not z-fighting the ground it traces.
|
|
35
|
+
*
|
|
36
|
+
* Two centimetres only works because the ring samples `surfaceHeightAt` — the
|
|
37
|
+
* plane of the triangle under each point — and not the bilinear `heightAt`. Those
|
|
38
|
+
* differ by up to a quarter of a cell's warp, which on steep ground is decimetres,
|
|
39
|
+
* so a bilinear ring would submerge on half of every cell no matter how large this
|
|
40
|
+
* gets. `depthTest={false}` hides that on screen but not in the exported GLB.
|
|
41
|
+
*/
|
|
42
|
+
const RING_LIFT = 0.02
|
|
43
|
+
|
|
44
|
+
/** The ring is decoration; it must never intercept a pointer ray. */
|
|
45
|
+
const NO_RAYCAST = () => null
|
|
46
|
+
|
|
47
|
+
export type TerrainBrushFocus = {
|
|
48
|
+
radius: number
|
|
49
|
+
x: number
|
|
50
|
+
z: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The brush ring, drawn *on the terrain surface* rather than as a flat disc.
|
|
55
|
+
*
|
|
56
|
+
* Following the surface is the whole point: a flat ring on sloped ground tells
|
|
57
|
+
* the user nothing about what the brush will touch, because the footprint is a
|
|
58
|
+
* cylinder in XZ and the interesting question is which part of the slope falls
|
|
59
|
+
* inside it. Sampling the ring's height per segment answers that directly.
|
|
60
|
+
*
|
|
61
|
+
* Updated in `useFrame` from a ref, never through React state — the ring tracks
|
|
62
|
+
* the pointer, and routing that through a re-render would re-run every hook in
|
|
63
|
+
* the tool subtree at pointer rate.
|
|
64
|
+
*
|
|
65
|
+
* On `EDITOR_LAYER`, non-negotiably: an overlay mesh left on the scene layer
|
|
66
|
+
* poisons the MRT scene pass and makes FrontSide geometry render see-through.
|
|
67
|
+
*/
|
|
68
|
+
export const TerrainBrushCursor: React.FC<{
|
|
69
|
+
focusRef: MutableRefObject<TerrainBrushFocus | null>
|
|
70
|
+
site: SiteNode
|
|
71
|
+
}> = ({ focusRef, site }) => {
|
|
72
|
+
const { camera, gl } = useThree()
|
|
73
|
+
const radius = useEditor((state) => state.terrainBrush.radius)
|
|
74
|
+
const shape = useEditor((state) => state.terrainBrush.shape)
|
|
75
|
+
// The ring is the only thing at the point of action that can say what the next
|
|
76
|
+
// drag will do — its geometry is identical for all four verbs, so colour is the
|
|
77
|
+
// one channel left. Keep the mapping centralized in `lib/terrain-verb-color`.
|
|
78
|
+
const verb = useEditor((state) => state.terrainVerb)
|
|
79
|
+
const sampling = useEditor((state) => state.terrainSampling)
|
|
80
|
+
const color = brushRingColor(verb, sampling)
|
|
81
|
+
const unit = useViewer((state) => state.unit)
|
|
82
|
+
|
|
83
|
+
const lineRef = useRef<Line>(null)
|
|
84
|
+
const centerRef = useRef<Group>(null)
|
|
85
|
+
const heightRef = useRef<HTMLSpanElement>(null)
|
|
86
|
+
const lastHeightLabelRef = useRef('')
|
|
87
|
+
const pointerRef = useRef<{ x: number; y: number } | null>(null)
|
|
88
|
+
const raycaster = useRef(new Raycaster())
|
|
89
|
+
const ndc = useRef(new Vector2())
|
|
90
|
+
|
|
91
|
+
const geometry = useMemo(() => {
|
|
92
|
+
const positions = new Float32Array((RING_SEGMENTS + 1) * 3)
|
|
93
|
+
const buffer = new BufferGeometry()
|
|
94
|
+
buffer.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
95
|
+
return buffer
|
|
96
|
+
}, [])
|
|
97
|
+
useEffect(() => () => geometry.dispose(), [geometry])
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const canvas = gl.domElement
|
|
101
|
+
const track = (event: PointerEvent) => {
|
|
102
|
+
const rect = canvas.getBoundingClientRect()
|
|
103
|
+
pointerRef.current = {
|
|
104
|
+
x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
105
|
+
y: -((event.clientY - rect.top) / rect.height) * 2 + 1,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const freeze = () => {
|
|
109
|
+
pointerRef.current = null
|
|
110
|
+
}
|
|
111
|
+
canvas.addEventListener('pointermove', track)
|
|
112
|
+
canvas.addEventListener('pointerleave', freeze)
|
|
113
|
+
return () => {
|
|
114
|
+
canvas.removeEventListener('pointermove', track)
|
|
115
|
+
canvas.removeEventListener('pointerleave', freeze)
|
|
116
|
+
}
|
|
117
|
+
}, [gl])
|
|
118
|
+
|
|
119
|
+
useFrame(() => {
|
|
120
|
+
const line = lineRef.current
|
|
121
|
+
const center = centerRef.current
|
|
122
|
+
const pointer = pointerRef.current
|
|
123
|
+
if (!(line && center)) return
|
|
124
|
+
if (!(pointer || focusRef.current)) {
|
|
125
|
+
line.visible = false
|
|
126
|
+
center.visible = false
|
|
127
|
+
if (heightRef.current) heightRef.current.style.display = 'none'
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Two fields, deliberately. Aiming uses the stroke's *snapshot* so the ring
|
|
132
|
+
// sits exactly where the next dab will land — the tool raycasts the snapshot
|
|
133
|
+
// too, and a cursor that disagreed with the brush by even a few centimetres
|
|
134
|
+
// would read as lag. Drawing uses the live field so the ring drapes over the
|
|
135
|
+
// hill as it rises. Outside a stroke the two are the same field.
|
|
136
|
+
const stroke = useLiveTerrain.getState().strokeOf(site.id)
|
|
137
|
+
const surface: TerrainField = stroke?.field ?? terrainFieldOf(site) ?? sculptFieldForSite(site)
|
|
138
|
+
const aim: TerrainField = stroke?.snapshot ?? surface
|
|
139
|
+
|
|
140
|
+
let hit: { x: number; z: number } | null = null
|
|
141
|
+
if (pointer) {
|
|
142
|
+
ndc.current.set(pointer.x, pointer.y)
|
|
143
|
+
raycaster.current.setFromCamera(ndc.current, camera)
|
|
144
|
+
const { origin, direction } = raycaster.current.ray
|
|
145
|
+
hit = raycastTerrain(
|
|
146
|
+
aim,
|
|
147
|
+
[origin.x, origin.y, origin.z],
|
|
148
|
+
[direction.x, direction.y, direction.z],
|
|
149
|
+
)
|
|
150
|
+
if (hit && terrainPointInsideSite(site, hit.x, hit.z)) {
|
|
151
|
+
focusRef.current = { radius: Math.max(radius, minBrushRadius(aim)), x: hit.x, z: hit.z }
|
|
152
|
+
} else {
|
|
153
|
+
hit = null
|
|
154
|
+
focusRef.current = null
|
|
155
|
+
}
|
|
156
|
+
} else if (focusRef.current) {
|
|
157
|
+
hit = terrainPointInsideSite(site, focusRef.current.x, focusRef.current.z)
|
|
158
|
+
? focusRef.current
|
|
159
|
+
: null
|
|
160
|
+
}
|
|
161
|
+
if (!hit) {
|
|
162
|
+
line.visible = false
|
|
163
|
+
center.visible = false
|
|
164
|
+
if (heightRef.current) heightRef.current.style.display = 'none'
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
line.visible = true
|
|
168
|
+
center.visible = true
|
|
169
|
+
|
|
170
|
+
const attribute = line.geometry.getAttribute('position')
|
|
171
|
+
const array = attribute.array as Float32Array
|
|
172
|
+
// The same floor the tool applies at pointer-down. Drawing the stored radius
|
|
173
|
+
// instead would draw a ring smaller than the footprint the next dab actually
|
|
174
|
+
// gets — on a coarse lot the ring would be the one thing insisting the brush
|
|
175
|
+
// is 2 m wide while the stroke is 1.5 spacings wide.
|
|
176
|
+
const drawn = Math.max(radius, minBrushRadius(aim))
|
|
177
|
+
focusRef.current = { radius: drawn, x: hit.x, z: hit.z }
|
|
178
|
+
for (let index = 0; index <= RING_SEGMENTS; index++) {
|
|
179
|
+
const angle = (index / RING_SEGMENTS) * Math.PI * 2
|
|
180
|
+
// The square brush uses Chebyshev distance, so its rim is a square: scale
|
|
181
|
+
// the unit circle out to the box so the ring shows the actual footprint
|
|
182
|
+
// rather than an inscribed circle that under-reports it.
|
|
183
|
+
const cos = Math.cos(angle)
|
|
184
|
+
const sin = Math.sin(angle)
|
|
185
|
+
const scale =
|
|
186
|
+
shape === 'square' ? 1 / Math.max(Math.abs(cos), Math.abs(sin), Number.EPSILON) : 1
|
|
187
|
+
const x = hit.x + cos * drawn * scale
|
|
188
|
+
const z = hit.z + sin * drawn * scale
|
|
189
|
+
array[index * 3] = x
|
|
190
|
+
array[index * 3 + 1] = surfaceHeightAt(surface, x, z) + RING_LIFT
|
|
191
|
+
array[index * 3 + 2] = z
|
|
192
|
+
}
|
|
193
|
+
attribute.needsUpdate = true
|
|
194
|
+
line.geometry.computeBoundingSphere()
|
|
195
|
+
|
|
196
|
+
const centerHeight = surfaceHeightAt(surface, hit.x, hit.z)
|
|
197
|
+
center.position.set(hit.x, centerHeight + RING_LIFT, hit.z)
|
|
198
|
+
const heightLabel = `H ${formatMeasurement(centerHeight, unit)}`
|
|
199
|
+
if (heightRef.current) {
|
|
200
|
+
heightRef.current.style.display = ''
|
|
201
|
+
if (heightLabel !== lastHeightLabelRef.current) {
|
|
202
|
+
heightRef.current.textContent = heightLabel
|
|
203
|
+
lastHeightLabelRef.current = heightLabel
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<>
|
|
210
|
+
<line
|
|
211
|
+
frustumCulled={false}
|
|
212
|
+
geometry={geometry}
|
|
213
|
+
layers={EDITOR_LAYER}
|
|
214
|
+
raycast={NO_RAYCAST}
|
|
215
|
+
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
|
216
|
+
ref={lineRef}
|
|
217
|
+
renderOrder={13}
|
|
218
|
+
>
|
|
219
|
+
<lineBasicNodeMaterial color={color} depthTest={false} depthWrite={false} linewidth={2} />
|
|
220
|
+
</line>
|
|
221
|
+
<group ref={centerRef} visible={false}>
|
|
222
|
+
<mesh
|
|
223
|
+
layers={EDITOR_LAYER}
|
|
224
|
+
raycast={NO_RAYCAST}
|
|
225
|
+
renderOrder={14}
|
|
226
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
227
|
+
>
|
|
228
|
+
<circleGeometry args={[0.055, 24]} />
|
|
229
|
+
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} />
|
|
230
|
+
</mesh>
|
|
231
|
+
<Html
|
|
232
|
+
center
|
|
233
|
+
position={[0, 0.18, 0]}
|
|
234
|
+
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
235
|
+
zIndexRange={[20, 0]}
|
|
236
|
+
>
|
|
237
|
+
<span
|
|
238
|
+
className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-2 py-1 font-medium font-mono text-foreground text-xs shadow-sm backdrop-blur"
|
|
239
|
+
ref={heightRef}
|
|
240
|
+
/>
|
|
241
|
+
</Html>
|
|
242
|
+
</group>
|
|
243
|
+
</>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export default TerrainBrushCursor
|