@pascal-app/editor 0.8.0 → 0.9.2
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 +15 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +795 -28
- package/src/components/editor/editor-layout-mobile.tsx +8 -3
- package/src/components/editor/editor-layout-v2.tsx +76 -51
- package/src/components/editor/export-manager.tsx +69 -32
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
- package/src/components/editor/first-person/build-collider-world.ts +228 -83
- package/src/components/editor/first-person-controls.tsx +943 -27
- package/src/components/editor/floating-action-menu.tsx +676 -208
- package/src/components/editor/floating-building-action-menu.tsx +2 -2
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +6916 -12137
- package/src/components/editor/grid.tsx +197 -36
- package/src/components/editor/group-actions.ts +457 -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 +420 -0
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +396 -0
- package/src/components/editor/group-transform-shared.ts +489 -0
- package/src/components/editor/handles/handle-arrow.tsx +581 -0
- package/src/components/editor/handles/use-handle-drag.ts +246 -0
- package/src/components/editor/index.tsx +412 -162
- package/src/components/editor/measurement-pill.tsx +115 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +1646 -0
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +1106 -483
- package/src/components/editor/site-edge-labels.tsx +45 -21
- package/src/components/editor/slab-hole-highlights.tsx +474 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +140 -41
- package/src/components/editor/use-floorplan-background-placement.ts +190 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +19 -7
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +14 -19
- package/src/components/editor/wall-move-side-handles.tsx +870 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
- package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- 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-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +375 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
- package/src/components/tools/fence/fence-drafting.ts +141 -27
- package/src/components/tools/item/move-tool.tsx +45 -105
- package/src/components/tools/item/placement-math.test.ts +22 -0
- package/src/components/tools/item/placement-math.ts +59 -7
- package/src/components/tools/item/placement-strategies.ts +432 -16
- package/src/components/tools/item/placement-types.ts +22 -1
- package/src/components/tools/item/use-draft-node.ts +51 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
- package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
- package/src/components/tools/roof/roof-tool.tsx +350 -28
- package/src/components/tools/select/box-select-state.ts +88 -0
- package/src/components/tools/select/box-select-tool.tsx +442 -520
- 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 +570 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
- 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 +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +305 -0
- package/src/components/tools/shared/polygon-editor.tsx +866 -175
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +520 -16
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +2 -1
- package/src/components/tools/stair/stair-tool.tsx +342 -37
- package/src/components/tools/tool-manager.tsx +239 -62
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +248 -171
- package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +86 -82
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -170
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +3 -117
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +27 -93
- package/src/components/ui/action-menu/view-toggles.tsx +279 -90
- package/src/components/ui/command-palette/editor-commands.tsx +20 -15
- package/src/components/ui/command-palette/index.tsx +5 -3
- package/src/components/ui/controls/material-paint-panel.tsx +152 -0
- package/src/components/ui/controls/material-picker.tsx +87 -177
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +142 -46
- 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/floating-level-selector.tsx +47 -10
- 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 +235 -23
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
- 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 +40 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +17 -16
- package/src/components/ui/panels/panel-manager.tsx +55 -58
- package/src/components/ui/panels/panel-wrapper.tsx +238 -12
- package/src/components/ui/panels/parametric-inspector.tsx +482 -0
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- 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/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
- 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 +92 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
- 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 +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -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 +112 -39
- 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/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +75 -1
- 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-overlay.tsx +253 -61
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +64 -10
- package/src/hooks/use-ceiling-events.ts +176 -0
- package/src/hooks/use-drag-action.ts +127 -0
- package/src/hooks/use-keyboard.ts +493 -95
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +451 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/constants.ts +6 -2
- 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 +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +50 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +117 -0
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +10 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +339 -0
- package/src/lib/fresh-planar-placement.ts +130 -0
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +11 -1
- 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 +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +227 -48
- 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 +183 -0
- package/src/lib/measurements.ts +199 -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/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +100 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +7 -7
- 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 +196 -0
- package/src/lib/scene-clipboard.ts +332 -0
- package/src/lib/scene.ts +40 -8
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -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 +69 -10
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +148 -55
- package/src/lib/site-boundary.ts +1 -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 +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +231 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +767 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +39 -0
- 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 +43 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +34 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -1,12 +1,86 @@
|
|
|
1
1
|
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|
4
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import {
|
|
6
|
+
BoxGeometry,
|
|
7
|
+
BufferGeometry,
|
|
8
|
+
Color,
|
|
9
|
+
CylinderGeometry,
|
|
10
|
+
DoubleSide,
|
|
11
|
+
ExtrudeGeometry,
|
|
12
|
+
Float32BufferAttribute,
|
|
13
|
+
type Line,
|
|
14
|
+
type Object3D,
|
|
15
|
+
Shape,
|
|
16
|
+
Vector3,
|
|
17
|
+
} from 'three'
|
|
18
|
+
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
5
19
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
6
20
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
21
|
+
import {
|
|
22
|
+
createMoveCrossHandleGeometry,
|
|
23
|
+
ARROW_COLOR as EDGE_ARROW_COLOR,
|
|
24
|
+
ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR,
|
|
25
|
+
ARROW_SCALE as EDGE_ARROW_SCALE,
|
|
26
|
+
useInvisibleHitAreaMaterial,
|
|
27
|
+
} from '../../editor/node-arrow-handles'
|
|
7
28
|
import { snapToHalf } from '../item/placement-math'
|
|
29
|
+
import { suppressBoxSelectForPointer } from '../select/box-select-state'
|
|
8
30
|
|
|
9
31
|
const Y_OFFSET = 0.02
|
|
32
|
+
// Per-side resize arrows: indigo chevrons that match the registry arrow
|
|
33
|
+
// handles (wall / column / fence). Each arrow sits just outside an edge
|
|
34
|
+
// midpoint, pointing along the edge's outward normal — dragging an arrow
|
|
35
|
+
// translates that edge only (its two vertices), leaving the opposite side
|
|
36
|
+
// fixed. Reuses the existing 'edge' drag mode in PolygonEditor.
|
|
37
|
+
const EDGE_ARROW_OFFSET = 0.34
|
|
38
|
+
|
|
39
|
+
// Disables R3F pointer-picking on a mesh. Used on the visual-only meshes —
|
|
40
|
+
// the edge bar and the border line — so they render without stealing pointer
|
|
41
|
+
// events that belong to the vertex/midpoint handles overlapping them. Mirrors
|
|
42
|
+
// the `NO_RAYCAST` sentinel in node-arrow-handles.tsx.
|
|
43
|
+
const NO_RAYCAST = () => null
|
|
44
|
+
|
|
45
|
+
// Maps the screen-space direction of an edge's outward normal to the closest
|
|
46
|
+
// directional resize cursor, so an edge arrow advertises the axis it will
|
|
47
|
+
// actually drag along regardless of camera orbit. Input is an NDC delta
|
|
48
|
+
// (y up); the angle is folded to [0°, 180°) since resize cursors are
|
|
49
|
+
// bidirectional.
|
|
50
|
+
function resizeCursorForScreenDirection(dx: number, dy: number): string {
|
|
51
|
+
const angle = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 180) + 180) % 180
|
|
52
|
+
if (angle < 22.5 || angle >= 157.5) return 'ew-resize'
|
|
53
|
+
if (angle < 67.5) return 'nesw-resize'
|
|
54
|
+
if (angle < 112.5) return 'ns-resize'
|
|
55
|
+
return 'nwse-resize'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createEdgeArrowGeometry() {
|
|
59
|
+
const shape = new Shape()
|
|
60
|
+
shape.moveTo(0.22, 0)
|
|
61
|
+
shape.lineTo(-0.04, 0.12)
|
|
62
|
+
shape.lineTo(-0.04, 0.035)
|
|
63
|
+
shape.lineTo(-0.2, 0.035)
|
|
64
|
+
shape.lineTo(-0.2, -0.035)
|
|
65
|
+
shape.lineTo(-0.04, -0.035)
|
|
66
|
+
shape.lineTo(-0.04, -0.12)
|
|
67
|
+
shape.lineTo(0.22, 0)
|
|
68
|
+
const geometry = new ExtrudeGeometry(shape, {
|
|
69
|
+
depth: 0.045,
|
|
70
|
+
bevelEnabled: true,
|
|
71
|
+
bevelThickness: 0.018,
|
|
72
|
+
bevelSize: 0.02,
|
|
73
|
+
bevelOffset: 0,
|
|
74
|
+
bevelSegments: 8,
|
|
75
|
+
curveSegments: 16,
|
|
76
|
+
steps: 1,
|
|
77
|
+
})
|
|
78
|
+
geometry.translate(0, 0, -0.0225)
|
|
79
|
+
geometry.rotateX(-Math.PI / 2)
|
|
80
|
+
geometry.computeVertexNormals()
|
|
81
|
+
geometry.computeBoundingSphere()
|
|
82
|
+
return geometry
|
|
83
|
+
}
|
|
10
84
|
|
|
11
85
|
type DragState = {
|
|
12
86
|
isDragging: boolean
|
|
@@ -19,10 +93,29 @@ type DragState = {
|
|
|
19
93
|
pointerId: number
|
|
20
94
|
}
|
|
21
95
|
|
|
96
|
+
export type PolygonEditorPlanPointSnapContext = {
|
|
97
|
+
rawPoint: [number, number]
|
|
98
|
+
gridPoint: [number, number]
|
|
99
|
+
mode: DragState['mode']
|
|
100
|
+
vertexIndex: number | null
|
|
101
|
+
edgeIndex?: number
|
|
102
|
+
initialPosition: [number, number]
|
|
103
|
+
initialPolygon: Array<[number, number]>
|
|
104
|
+
nativeEvent?: GridEvent['nativeEvent']
|
|
105
|
+
}
|
|
106
|
+
|
|
22
107
|
export interface PolygonEditorProps {
|
|
23
108
|
polygon: Array<[number, number]>
|
|
24
109
|
color?: string
|
|
25
110
|
onPolygonChange: (polygon: Array<[number, number]>) => void
|
|
111
|
+
/**
|
|
112
|
+
* Fires on every drag tick with the in-flight polygon, then once with
|
|
113
|
+
* `null` when the drag commits or is otherwise cleared. Hosts wire
|
|
114
|
+
* this to `useLiveNodeOverrides` so the underlying mesh rebuilds at
|
|
115
|
+
* pointer rate while `onPolygonChange` stays a single store commit
|
|
116
|
+
* on release.
|
|
117
|
+
*/
|
|
118
|
+
onPolygonPreview?: (polygon: ReadonlyArray<readonly [number, number]> | null) => void
|
|
26
119
|
minVertices?: number
|
|
27
120
|
/** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
|
|
28
121
|
levelId?: string
|
|
@@ -32,6 +125,32 @@ export interface PolygonEditorProps {
|
|
|
32
125
|
allowPolygonMove?: boolean
|
|
33
126
|
/** Whether polygon edges can be dragged along their perpendicular normal. */
|
|
34
127
|
allowEdgeMove?: boolean
|
|
128
|
+
/** Called just before a vertex drag session starts. */
|
|
129
|
+
onBeforeVertexDrag?: (vertexIndex: number, position: [number, number]) => void
|
|
130
|
+
/** Called when a vertex handle enters or leaves hover. */
|
|
131
|
+
onVertexHoverChange?: (vertexIndex: number | null) => void
|
|
132
|
+
/** Called when a midpoint add-vertex handle enters or leaves hover. */
|
|
133
|
+
onMidpointHoverChange?: (edgeIndex: number | null) => void
|
|
134
|
+
/** Called when an edge move handle enters or leaves hover. */
|
|
135
|
+
onEdgeHoverChange?: (edgeIndex: number | null) => void
|
|
136
|
+
/** Called when any polygon drag starts or ends. */
|
|
137
|
+
onDragStateChange?: (isDragging: boolean) => void
|
|
138
|
+
/** Called once when a polygon drag starts. */
|
|
139
|
+
onDragStart?: () => void
|
|
140
|
+
/** Called once when a polygon drag commits on pointer release. */
|
|
141
|
+
onDragCommit?: () => void
|
|
142
|
+
/** Whether to render the editor-owned polygon outline. */
|
|
143
|
+
showBorderLine?: boolean
|
|
144
|
+
/** Whether midpoint handles can add new vertices. */
|
|
145
|
+
showMidpointHandles?: boolean
|
|
146
|
+
/** Whether hovering a handle should also tint its connected edges and endpoint handles. */
|
|
147
|
+
highlightConnectedHandles?: boolean
|
|
148
|
+
/** Optional host-owned point snapper. Defaults to the existing half-grid snap. */
|
|
149
|
+
resolvePlanPoint?: (context: PolygonEditorPlanPointSnapContext) => [number, number]
|
|
150
|
+
/** Optional vertex handle renderer for host-specific affordances. */
|
|
151
|
+
renderVertexHandle?: PolygonVertexHandleRenderer
|
|
152
|
+
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */
|
|
153
|
+
renderMidpointHandle?: PolygonMidpointHandleRenderer
|
|
35
154
|
}
|
|
36
155
|
|
|
37
156
|
/**
|
|
@@ -41,6 +160,7 @@ export interface PolygonEditorProps {
|
|
|
41
160
|
const MIN_HANDLE_HEIGHT = 0.15
|
|
42
161
|
const EDGE_HANDLE_HEIGHT = 0.06
|
|
43
162
|
const EDGE_HANDLE_THICKNESS = 0.12
|
|
163
|
+
const EDGE_HANDLE_GEOMETRY = new BoxGeometry(1, 1, 1)
|
|
44
164
|
|
|
45
165
|
function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
|
|
46
166
|
const dx = end[0] - start[0]
|
|
@@ -51,15 +171,267 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number,
|
|
|
51
171
|
return [-dz / length, dx / length]
|
|
52
172
|
}
|
|
53
173
|
|
|
174
|
+
function stopHandlePointerDown(event: ThreeEvent<PointerEvent>) {
|
|
175
|
+
event.stopPropagation()
|
|
176
|
+
suppressBoxSelectForPointer(event, { markHandled: false })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type HandleClickHandler = (event: ThreeEvent<MouseEvent>) => void
|
|
180
|
+
type HandlePointerHandler = (event: ThreeEvent<PointerEvent>) => void
|
|
181
|
+
|
|
182
|
+
export type PolygonHandleHandlers = {
|
|
183
|
+
onClick?: HandleClickHandler
|
|
184
|
+
onDoubleClick?: HandleClickHandler
|
|
185
|
+
onPointerDown?: HandlePointerHandler
|
|
186
|
+
onPointerEnter?: HandlePointerHandler
|
|
187
|
+
onPointerLeave?: HandlePointerHandler
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type PolygonVertexHandleRenderProps = {
|
|
191
|
+
canDelete: boolean
|
|
192
|
+
handleProps: PolygonHandleHandlers
|
|
193
|
+
height: number
|
|
194
|
+
index: number
|
|
195
|
+
isDragging: boolean
|
|
196
|
+
isHovered: boolean
|
|
197
|
+
point: [number, number]
|
|
198
|
+
position: [number, number, number]
|
|
199
|
+
radius: number
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type PolygonVertexHandleRenderer = (props: PolygonVertexHandleRenderProps) => React.ReactNode
|
|
203
|
+
|
|
204
|
+
export type PolygonMidpointHandleRenderProps = {
|
|
205
|
+
handleProps: PolygonHandleHandlers
|
|
206
|
+
height: number
|
|
207
|
+
index: number
|
|
208
|
+
isHovered: boolean
|
|
209
|
+
point: [number, number]
|
|
210
|
+
position: [number, number, number]
|
|
211
|
+
radius: number
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type PolygonMidpointHandleRenderer = (
|
|
215
|
+
props: PolygonMidpointHandleRenderProps,
|
|
216
|
+
) => React.ReactNode
|
|
217
|
+
|
|
218
|
+
function usePolygonNodeMaterial(
|
|
219
|
+
color: string,
|
|
220
|
+
opacity = 1,
|
|
221
|
+
depthTest = true,
|
|
222
|
+
): MeshBasicNodeMaterial {
|
|
223
|
+
const material = useMemo(
|
|
224
|
+
() =>
|
|
225
|
+
new MeshBasicNodeMaterial({
|
|
226
|
+
color: new Color('#ffffff'),
|
|
227
|
+
depthTest: true,
|
|
228
|
+
depthWrite: true,
|
|
229
|
+
opacity: 1,
|
|
230
|
+
transparent: true,
|
|
231
|
+
}),
|
|
232
|
+
[],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
material.color.set(color)
|
|
237
|
+
material.opacity = opacity
|
|
238
|
+
material.depthTest = depthTest
|
|
239
|
+
}, [color, depthTest, material, opacity])
|
|
240
|
+
useEffect(() => () => material.dispose(), [material])
|
|
241
|
+
|
|
242
|
+
return material
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function usePolygonArrowMaterial(): MeshBasicNodeMaterial {
|
|
246
|
+
return useMemo(
|
|
247
|
+
() =>
|
|
248
|
+
new MeshBasicNodeMaterial({
|
|
249
|
+
color: new Color(EDGE_ARROW_COLOR),
|
|
250
|
+
depthTest: false,
|
|
251
|
+
depthWrite: true,
|
|
252
|
+
opacity: 1,
|
|
253
|
+
side: DoubleSide,
|
|
254
|
+
transparent: true,
|
|
255
|
+
}),
|
|
256
|
+
[],
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// One mesh per handle: lives on SCENE_LAYER with a node material so the
|
|
261
|
+
// post-processing ink-edge pass outlines it. Ignores scene depth like the
|
|
262
|
+
// edge arrows so the handle stays visible through walls and slabs.
|
|
263
|
+
function OutlinedCylinderHandle({
|
|
264
|
+
radius,
|
|
265
|
+
height,
|
|
266
|
+
color,
|
|
267
|
+
opacity = 1,
|
|
268
|
+
position,
|
|
269
|
+
...handlers
|
|
270
|
+
}: {
|
|
271
|
+
radius: number
|
|
272
|
+
height: number
|
|
273
|
+
color: string
|
|
274
|
+
opacity?: number
|
|
275
|
+
position: [number, number, number]
|
|
276
|
+
} & PolygonHandleHandlers) {
|
|
277
|
+
const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius])
|
|
278
|
+
const material = usePolygonNodeMaterial(color, opacity, false)
|
|
279
|
+
useEffect(() => () => geometry.dispose(), [geometry])
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<mesh
|
|
283
|
+
frustumCulled={false}
|
|
284
|
+
geometry={geometry}
|
|
285
|
+
layers={SCENE_LAYER}
|
|
286
|
+
material={material}
|
|
287
|
+
position={position}
|
|
288
|
+
renderOrder={1010}
|
|
289
|
+
{...handlers}
|
|
290
|
+
/>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Whole-polygon move grip — the generic 4-way cross-arrow (matching the node
|
|
295
|
+
// move handles) with an invisible cylinder hit area, replacing the old sphere.
|
|
296
|
+
// The cross sits on SCENE_LAYER (so the ink pass outlines it) while the
|
|
297
|
+
// cylinder hit mesh is on EDITOR_LAYER (grabbable, out of the MRT scene pass).
|
|
298
|
+
function OutlinedCrossHandle({
|
|
299
|
+
color,
|
|
300
|
+
position,
|
|
301
|
+
...handlers
|
|
302
|
+
}: {
|
|
303
|
+
color: string
|
|
304
|
+
position: [number, number, number]
|
|
305
|
+
} & PolygonHandleHandlers) {
|
|
306
|
+
const geometry = useMemo(() => createMoveCrossHandleGeometry(), [])
|
|
307
|
+
const material = usePolygonNodeMaterial(color)
|
|
308
|
+
const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), [])
|
|
309
|
+
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
310
|
+
useEffect(() => () => geometry.dispose(), [geometry])
|
|
311
|
+
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<group position={position}>
|
|
315
|
+
<mesh
|
|
316
|
+
frustumCulled={false}
|
|
317
|
+
geometry={geometry}
|
|
318
|
+
layers={SCENE_LAYER}
|
|
319
|
+
material={material}
|
|
320
|
+
raycast={NO_RAYCAST}
|
|
321
|
+
renderOrder={1010}
|
|
322
|
+
scale={EDGE_ARROW_SCALE}
|
|
323
|
+
/>
|
|
324
|
+
<mesh
|
|
325
|
+
frustumCulled={false}
|
|
326
|
+
geometry={hitGeometry}
|
|
327
|
+
layers={EDITOR_LAYER}
|
|
328
|
+
material={hitMaterial}
|
|
329
|
+
renderOrder={1011}
|
|
330
|
+
{...handlers}
|
|
331
|
+
/>
|
|
332
|
+
</group>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function OutlinedEdgeArrowHandle({
|
|
337
|
+
geometry,
|
|
338
|
+
color,
|
|
339
|
+
position,
|
|
340
|
+
rotationY,
|
|
341
|
+
scale,
|
|
342
|
+
...handlers
|
|
343
|
+
}: {
|
|
344
|
+
geometry: BufferGeometry
|
|
345
|
+
color: string
|
|
346
|
+
position: [number, number, number]
|
|
347
|
+
rotationY: number
|
|
348
|
+
scale: number
|
|
349
|
+
} & PolygonHandleHandlers) {
|
|
350
|
+
const material = usePolygonArrowMaterial()
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
material.color.set(color)
|
|
353
|
+
}, [color, material])
|
|
354
|
+
useEffect(() => () => material.dispose(), [material])
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<mesh
|
|
358
|
+
frustumCulled={false}
|
|
359
|
+
geometry={geometry}
|
|
360
|
+
layers={SCENE_LAYER}
|
|
361
|
+
material={material}
|
|
362
|
+
position={position}
|
|
363
|
+
renderOrder={1010}
|
|
364
|
+
rotation={[0, rotationY, 0]}
|
|
365
|
+
scale={scale}
|
|
366
|
+
{...handlers}
|
|
367
|
+
/>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function HighlightedEdgeSegment({
|
|
372
|
+
end,
|
|
373
|
+
start,
|
|
374
|
+
y,
|
|
375
|
+
}: {
|
|
376
|
+
end: [number, number]
|
|
377
|
+
start: [number, number]
|
|
378
|
+
y: number
|
|
379
|
+
}) {
|
|
380
|
+
const geometry = useMemo(() => {
|
|
381
|
+
const nextGeometry = new BufferGeometry()
|
|
382
|
+
nextGeometry.setAttribute(
|
|
383
|
+
'position',
|
|
384
|
+
new Float32BufferAttribute([start[0], y, start[1], end[0], y, end[1]], 3),
|
|
385
|
+
)
|
|
386
|
+
return nextGeometry
|
|
387
|
+
}, [end, start, y])
|
|
388
|
+
|
|
389
|
+
useEffect(() => () => geometry.dispose(), [geometry])
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<line
|
|
393
|
+
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
|
394
|
+
frustumCulled={false}
|
|
395
|
+
geometry={geometry}
|
|
396
|
+
layers={EDITOR_LAYER}
|
|
397
|
+
raycast={NO_RAYCAST}
|
|
398
|
+
renderOrder={12}
|
|
399
|
+
>
|
|
400
|
+
<lineBasicNodeMaterial
|
|
401
|
+
color={EDGE_ARROW_HOVER_COLOR}
|
|
402
|
+
depthTest
|
|
403
|
+
depthWrite={false}
|
|
404
|
+
linewidth={4}
|
|
405
|
+
opacity={0.95}
|
|
406
|
+
transparent
|
|
407
|
+
/>
|
|
408
|
+
</line>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
54
412
|
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
55
413
|
polygon,
|
|
56
414
|
color = '#3b82f6',
|
|
57
415
|
onPolygonChange,
|
|
416
|
+
onPolygonPreview,
|
|
58
417
|
minVertices = 3,
|
|
59
418
|
levelId,
|
|
60
419
|
surfaceHeight = 0,
|
|
61
420
|
allowPolygonMove = false,
|
|
62
421
|
allowEdgeMove = false,
|
|
422
|
+
onBeforeVertexDrag,
|
|
423
|
+
onVertexHoverChange,
|
|
424
|
+
onMidpointHoverChange,
|
|
425
|
+
onEdgeHoverChange,
|
|
426
|
+
onDragStateChange,
|
|
427
|
+
onDragStart,
|
|
428
|
+
onDragCommit,
|
|
429
|
+
showBorderLine = true,
|
|
430
|
+
showMidpointHandles = true,
|
|
431
|
+
highlightConnectedHandles = false,
|
|
432
|
+
resolvePlanPoint,
|
|
433
|
+
renderMidpointHandle,
|
|
434
|
+
renderVertexHandle,
|
|
63
435
|
}) => {
|
|
64
436
|
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
|
65
437
|
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
|
@@ -100,14 +472,73 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
100
472
|
// When not using portal, edit at world origin
|
|
101
473
|
const editY = levelNode ? Y_OFFSET : 0
|
|
102
474
|
|
|
475
|
+
const camera = useThree((state) => state.camera)
|
|
476
|
+
|
|
477
|
+
// Handle-hover cursor, applied to document.body like the registry arrow
|
|
478
|
+
// handles (see handle-arrow.tsx). Tracked in a ref so we only ever clear a
|
|
479
|
+
// cursor we set ourselves.
|
|
480
|
+
const activeBodyCursorRef = useRef<string | null>(null)
|
|
481
|
+
const setBodyCursor = useCallback((cursor: string) => {
|
|
482
|
+
activeBodyCursorRef.current = cursor
|
|
483
|
+
document.body.style.cursor = cursor
|
|
484
|
+
}, [])
|
|
485
|
+
const clearBodyCursor = useCallback(() => {
|
|
486
|
+
if (activeBodyCursorRef.current && document.body.style.cursor === activeBodyCursorRef.current) {
|
|
487
|
+
document.body.style.cursor = ''
|
|
488
|
+
}
|
|
489
|
+
activeBodyCursorRef.current = null
|
|
490
|
+
}, [])
|
|
491
|
+
useEffect(() => () => clearBodyCursor(), [clearBodyCursor])
|
|
492
|
+
|
|
493
|
+
const edgeResizeCursor = useCallback(
|
|
494
|
+
(midpoint: [number, number], outwardNormal: [number, number]) => {
|
|
495
|
+
const from = new Vector3(midpoint[0], editY, midpoint[1])
|
|
496
|
+
const to = new Vector3(midpoint[0] + outwardNormal[0], editY, midpoint[1] + outwardNormal[1])
|
|
497
|
+
if (levelNode) {
|
|
498
|
+
levelNode.localToWorld(from)
|
|
499
|
+
levelNode.localToWorld(to)
|
|
500
|
+
}
|
|
501
|
+
from.project(camera)
|
|
502
|
+
to.project(camera)
|
|
503
|
+
return resizeCursorForScreenDirection(to.x - from.x, to.y - from.y)
|
|
504
|
+
},
|
|
505
|
+
[camera, editY, levelNode],
|
|
506
|
+
)
|
|
507
|
+
|
|
103
508
|
// Local state for dragging
|
|
104
509
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
105
510
|
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
|
106
511
|
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
|
512
|
+
const previousInputDraggingRef = useRef(false)
|
|
513
|
+
|
|
514
|
+
const onDragStateChangeRef = useRef(onDragStateChange)
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
onDragStateChangeRef.current = onDragStateChange
|
|
517
|
+
}, [onDragStateChange])
|
|
518
|
+
|
|
519
|
+
const onDragStartRef = useRef(onDragStart)
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
onDragStartRef.current = onDragStart
|
|
522
|
+
}, [onDragStart])
|
|
523
|
+
|
|
524
|
+
const onDragCommitRef = useRef(onDragCommit)
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
onDragCommitRef.current = onDragCommit
|
|
527
|
+
}, [onDragCommit])
|
|
528
|
+
|
|
529
|
+
const onPolygonPreviewRef = useRef(onPolygonPreview)
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
onPolygonPreviewRef.current = onPolygonPreview
|
|
532
|
+
}, [onPolygonPreview])
|
|
107
533
|
|
|
108
534
|
const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
|
|
109
535
|
previewPolygonRef.current = nextPolygon
|
|
110
536
|
setPreviewPolygon(nextPolygon)
|
|
537
|
+
// Notify the host so it can mirror the in-flight polygon onto
|
|
538
|
+
// `useLiveNodeOverrides` (drag rebuilds the mesh at pointer rate)
|
|
539
|
+
// and clear that override when we hand `null` back at commit /
|
|
540
|
+
// cancel / external-undo time.
|
|
541
|
+
onPolygonPreviewRef.current?.(nextPolygon)
|
|
111
542
|
}, [])
|
|
112
543
|
|
|
113
544
|
// Keep ref in sync
|
|
@@ -120,18 +551,83 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
120
551
|
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
|
|
121
552
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
|
122
553
|
|
|
554
|
+
useEffect(() => {
|
|
555
|
+
onVertexHoverChange?.(hoveredVertex)
|
|
556
|
+
}, [hoveredVertex, onVertexHoverChange])
|
|
557
|
+
|
|
558
|
+
useEffect(() => () => onVertexHoverChange?.(null), [onVertexHoverChange])
|
|
559
|
+
|
|
560
|
+
useEffect(() => {
|
|
561
|
+
onMidpointHoverChange?.(hoveredMidpoint)
|
|
562
|
+
}, [hoveredMidpoint, onMidpointHoverChange])
|
|
563
|
+
|
|
564
|
+
useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange])
|
|
565
|
+
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
onEdgeHoverChange?.(hoveredEdge)
|
|
568
|
+
}, [hoveredEdge, onEdgeHoverChange])
|
|
569
|
+
|
|
570
|
+
useEffect(() => () => onEdgeHoverChange?.(null), [onEdgeHoverChange])
|
|
571
|
+
|
|
123
572
|
const lineRef = useRef<Line>(null!)
|
|
124
573
|
const previousPositionRef = useRef<[number, number] | null>(null)
|
|
125
574
|
|
|
126
|
-
|
|
575
|
+
useEffect(() => {
|
|
576
|
+
onDragStateChangeRef.current?.(dragState?.isDragging ?? false)
|
|
577
|
+
}, [dragState?.isDragging])
|
|
578
|
+
|
|
579
|
+
useEffect(() => () => onDragStateChangeRef.current?.(false), [])
|
|
580
|
+
|
|
581
|
+
const startDrag = useCallback((nextDragState: DragState) => {
|
|
582
|
+
previousInputDraggingRef.current = useViewer.getState().inputDragging
|
|
583
|
+
useViewer.getState().setInputDragging(true)
|
|
584
|
+
setDragState(nextDragState)
|
|
585
|
+
onDragStartRef.current?.()
|
|
586
|
+
}, [])
|
|
587
|
+
|
|
588
|
+
useEffect(() => {
|
|
589
|
+
if (!dragState?.isDragging) return
|
|
590
|
+
|
|
591
|
+
return () => {
|
|
592
|
+
useViewer.getState().setInputDragging(previousInputDraggingRef.current)
|
|
593
|
+
}
|
|
594
|
+
}, [dragState?.isDragging])
|
|
595
|
+
|
|
596
|
+
// Track the last polygon prop to detect external changes (undo/redo) or
|
|
597
|
+
// our own post-commit prop update arriving while a preview is still in
|
|
598
|
+
// flight. Either way, drop the stale preview/drag.
|
|
599
|
+
//
|
|
600
|
+
// This block runs during render, so it may only touch THIS component's
|
|
601
|
+
// own state (setPreviewPolygon / setDragState — React re-renders in
|
|
602
|
+
// place, which is the sanctioned "adjust state on prop change" pattern).
|
|
603
|
+
// The host `onPolygonPreview(null)` notification clears
|
|
604
|
+
// `useLiveNodeOverrides` — a store OTHER components subscribe to (e.g.
|
|
605
|
+
// NodeArrowHandles) — so calling it here throws "Cannot update a
|
|
606
|
+
// component while rendering a different component". Defer it to the
|
|
607
|
+
// effect below.
|
|
127
608
|
const lastPolygonRef = useRef(polygon)
|
|
609
|
+
const pendingPreviewClearRef = useRef(false)
|
|
128
610
|
if (polygon !== lastPolygonRef.current) {
|
|
129
611
|
lastPolygonRef.current = polygon
|
|
130
|
-
//
|
|
131
|
-
|
|
612
|
+
// `previewPolygonRef` is the synchronously-updated source of truth for
|
|
613
|
+
// an in-flight preview (state can lag it by a render).
|
|
614
|
+
if (previewPolygonRef.current !== null || previewPolygon !== null) {
|
|
615
|
+
previewPolygonRef.current = null
|
|
616
|
+
setPreviewPolygon(null)
|
|
617
|
+
pendingPreviewClearRef.current = true
|
|
618
|
+
}
|
|
132
619
|
if (dragState) setDragState(null)
|
|
133
620
|
}
|
|
134
621
|
|
|
622
|
+
// Flush the deferred host preview-clear (see note above) after the
|
|
623
|
+
// commit, where writing to other stores is allowed.
|
|
624
|
+
useEffect(() => {
|
|
625
|
+
if (pendingPreviewClearRef.current) {
|
|
626
|
+
pendingPreviewClearRef.current = false
|
|
627
|
+
onPolygonPreviewRef.current?.(null)
|
|
628
|
+
}
|
|
629
|
+
})
|
|
630
|
+
|
|
135
631
|
// The polygon to display (preview during drag, or actual polygon)
|
|
136
632
|
const displayPolygon = previewPolygon ?? polygon
|
|
137
633
|
|
|
@@ -159,6 +655,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
159
655
|
const edgeHandles = useMemo(() => {
|
|
160
656
|
if (displayPolygon.length < 2) return []
|
|
161
657
|
|
|
658
|
+
let cx = 0
|
|
659
|
+
let cz = 0
|
|
660
|
+
for (const [x, z] of displayPolygon) {
|
|
661
|
+
cx += x
|
|
662
|
+
cz += z
|
|
663
|
+
}
|
|
664
|
+
cx /= displayPolygon.length
|
|
665
|
+
cz /= displayPolygon.length
|
|
666
|
+
|
|
162
667
|
return displayPolygon.flatMap(([x1, z1], index) => {
|
|
163
668
|
const nextIndex = (index + 1) % displayPolygon.length
|
|
164
669
|
const [x2, z2] = displayPolygon[nextIndex]!
|
|
@@ -167,17 +672,74 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
167
672
|
const length = Math.hypot(dx, dz)
|
|
168
673
|
if (length < 1e-6) return []
|
|
169
674
|
|
|
675
|
+
const midpoint: [number, number] = [(x1 + x2) / 2, (z1 + z2) / 2]
|
|
676
|
+
// Outward normal: edge perpendicular flipped to point away from the
|
|
677
|
+
// polygon centroid. Independent of winding order so arrows always
|
|
678
|
+
// face outward even on hand-traced (mixed-winding) polygons.
|
|
679
|
+
let nx = -dz / length
|
|
680
|
+
let nz = dx / length
|
|
681
|
+
if (nx * (midpoint[0] - cx) + nz * (midpoint[1] - cz) < 0) {
|
|
682
|
+
nx = -nx
|
|
683
|
+
nz = -nz
|
|
684
|
+
}
|
|
685
|
+
|
|
170
686
|
return [
|
|
171
687
|
{
|
|
172
688
|
index,
|
|
173
689
|
length,
|
|
174
|
-
midpoint
|
|
690
|
+
midpoint,
|
|
175
691
|
rotationY: -Math.atan2(dz, dx),
|
|
692
|
+
outwardNormal: [nx, nz] as [number, number],
|
|
693
|
+
outwardAngle: -Math.atan2(nz, nx),
|
|
176
694
|
},
|
|
177
695
|
]
|
|
178
696
|
})
|
|
179
697
|
}, [displayPolygon])
|
|
180
698
|
|
|
699
|
+
const activeVertexIndex = dragState?.mode === 'vertex' ? dragState.vertexIndex : hoveredVertex
|
|
700
|
+
const activeEdgeIndex = dragState?.mode === 'edge' ? dragState.edgeIndex : hoveredEdge
|
|
701
|
+
|
|
702
|
+
const highlightedEdgeIndices = useMemo(() => {
|
|
703
|
+
const next = new Set<number>()
|
|
704
|
+
const edgeCount = displayPolygon.length
|
|
705
|
+
if (!highlightConnectedHandles || edgeCount < 2) return next
|
|
706
|
+
|
|
707
|
+
if (activeVertexIndex !== null && activeVertexIndex !== undefined) {
|
|
708
|
+
next.add(activeVertexIndex)
|
|
709
|
+
next.add((activeVertexIndex - 1 + edgeCount) % edgeCount)
|
|
710
|
+
}
|
|
711
|
+
if (hoveredMidpoint !== null) {
|
|
712
|
+
next.add(hoveredMidpoint)
|
|
713
|
+
}
|
|
714
|
+
if (activeEdgeIndex !== null && activeEdgeIndex !== undefined) {
|
|
715
|
+
next.add(activeEdgeIndex)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return next
|
|
719
|
+
}, [
|
|
720
|
+
activeEdgeIndex,
|
|
721
|
+
activeVertexIndex,
|
|
722
|
+
displayPolygon.length,
|
|
723
|
+
highlightConnectedHandles,
|
|
724
|
+
hoveredMidpoint,
|
|
725
|
+
])
|
|
726
|
+
|
|
727
|
+
const isVertexLinkedHighlighted = useCallback(
|
|
728
|
+
(index: number) => {
|
|
729
|
+
if (!highlightConnectedHandles || highlightedEdgeIndices.size === 0) return false
|
|
730
|
+
const edgeCount = displayPolygon.length
|
|
731
|
+
if (edgeCount < 2) return false
|
|
732
|
+
return (
|
|
733
|
+
highlightedEdgeIndices.has(index) ||
|
|
734
|
+
highlightedEdgeIndices.has((index - 1 + edgeCount) % edgeCount)
|
|
735
|
+
)
|
|
736
|
+
},
|
|
737
|
+
[displayPolygon.length, highlightConnectedHandles, highlightedEdgeIndices],
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
|
|
741
|
+
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
|
742
|
+
|
|
181
743
|
// Update vertex position using grid cursor position
|
|
182
744
|
const handleVertexDrag = useCallback(
|
|
183
745
|
(vertexIndex: number, position: [number, number]) => {
|
|
@@ -194,9 +756,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
194
756
|
if (previewPolygonRef.current) {
|
|
195
757
|
onPolygonChange(previewPolygonRef.current)
|
|
196
758
|
}
|
|
759
|
+
onDragCommitRef.current?.()
|
|
197
760
|
updatePreviewPolygon(null)
|
|
198
761
|
setDragState(null)
|
|
199
|
-
|
|
762
|
+
clearBodyCursor()
|
|
763
|
+
}, [clearBodyCursor, onPolygonChange, updatePreviewPolygon])
|
|
200
764
|
|
|
201
765
|
// Handle adding a new vertex at midpoint
|
|
202
766
|
const handleAddVertex = useCallback(
|
|
@@ -233,9 +797,24 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
233
797
|
// Listen to grid:move events to track cursor position
|
|
234
798
|
useEffect(() => {
|
|
235
799
|
const onGridMove = (event: GridEvent) => {
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
|
|
800
|
+
const point = levelNode ? event.localPosition : event.position
|
|
801
|
+
const rawPoint: [number, number] = [point[0], point[2]]
|
|
802
|
+
// Snapping follows the active mode (snapToHalf returns raw in Off / non-grid);
|
|
803
|
+
// no Shift bypass — Shift cycles the mode, Off is the bypass.
|
|
804
|
+
const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
|
|
805
|
+
const newPosition =
|
|
806
|
+
dragState?.isDragging && resolvePlanPoint
|
|
807
|
+
? resolvePlanPoint({
|
|
808
|
+
rawPoint,
|
|
809
|
+
gridPoint,
|
|
810
|
+
mode: dragState.mode,
|
|
811
|
+
vertexIndex: dragState.vertexIndex,
|
|
812
|
+
edgeIndex: dragState.edgeIndex,
|
|
813
|
+
initialPosition: dragState.initialPosition,
|
|
814
|
+
initialPolygon: dragState.initialPolygon,
|
|
815
|
+
nativeEvent: event.nativeEvent,
|
|
816
|
+
})
|
|
817
|
+
: gridPoint
|
|
239
818
|
|
|
240
819
|
// Play snap sound when cursor moves to a new grid cell during drag
|
|
241
820
|
if (
|
|
@@ -290,7 +869,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
290
869
|
return () => {
|
|
291
870
|
emitter.off('grid:move', onGridMove)
|
|
292
871
|
}
|
|
293
|
-
}, [dragState, handleVertexDrag, updatePreviewPolygon])
|
|
872
|
+
}, [dragState, handleVertexDrag, levelNode, resolvePlanPoint, updatePreviewPolygon])
|
|
294
873
|
|
|
295
874
|
// Set up pointer up listener for ending drag
|
|
296
875
|
useEffect(() => {
|
|
@@ -335,7 +914,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
335
914
|
|
|
336
915
|
// Update line geometry when polygon changes
|
|
337
916
|
useEffect(() => {
|
|
338
|
-
if (!lineRef.current || displayPolygon.length < 2) return
|
|
917
|
+
if (!showBorderLine || !lineRef.current || displayPolygon.length < 2) return
|
|
339
918
|
|
|
340
919
|
const positions: number[] = []
|
|
341
920
|
for (const [x, z] of displayPolygon) {
|
|
@@ -350,7 +929,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
350
929
|
|
|
351
930
|
lineRef.current.geometry.dispose()
|
|
352
931
|
lineRef.current.geometry = geometry
|
|
353
|
-
}, [displayPolygon, editY])
|
|
932
|
+
}, [displayPolygon, editY, showBorderLine])
|
|
354
933
|
|
|
355
934
|
if (displayPolygon.length < minVertices) return null
|
|
356
935
|
|
|
@@ -358,95 +937,148 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
358
937
|
const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
|
|
359
938
|
const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
|
|
360
939
|
|
|
940
|
+
// Interactive handles are SCENE_LAYER node-material meshes so the ink-edge
|
|
941
|
+
// pass outlines them. Edge arrows ignore scene depth so the active resize
|
|
942
|
+
// affordance remains visible through walls and slabs. The edge BAR and border
|
|
943
|
+
// line stay on EDITOR_LAYER, visual-only
|
|
944
|
+
// (raycast disabled) so they never steal clicks from the vertex/midpoint
|
|
945
|
+
// handles overlapping them — edge dragging starts from the chevron arrow
|
|
946
|
+
// outside the polygon edge.
|
|
361
947
|
const editorContent = (
|
|
362
948
|
<group>
|
|
363
949
|
{/* Border line */}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
950
|
+
{showBorderLine && (
|
|
951
|
+
<line
|
|
952
|
+
frustumCulled={false}
|
|
953
|
+
layers={EDITOR_LAYER}
|
|
954
|
+
raycast={NO_RAYCAST}
|
|
955
|
+
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
|
956
|
+
ref={lineRef}
|
|
957
|
+
renderOrder={10}
|
|
958
|
+
>
|
|
959
|
+
<bufferGeometry />
|
|
960
|
+
<lineBasicNodeMaterial
|
|
961
|
+
color={color}
|
|
962
|
+
depthTest={false}
|
|
963
|
+
depthWrite={false}
|
|
964
|
+
linewidth={2}
|
|
965
|
+
opacity={0.8}
|
|
966
|
+
transparent
|
|
967
|
+
/>
|
|
968
|
+
</line>
|
|
969
|
+
)}
|
|
970
|
+
|
|
971
|
+
{highlightConnectedHandles &&
|
|
972
|
+
highlightedEdgeIndices.size > 0 &&
|
|
973
|
+
Array.from(highlightedEdgeIndices).map((edgeIndex) => {
|
|
974
|
+
const start = displayPolygon[edgeIndex]
|
|
975
|
+
const end = displayPolygon[(edgeIndex + 1) % displayPolygon.length]
|
|
976
|
+
if (!(start && end)) return null
|
|
977
|
+
return (
|
|
978
|
+
<HighlightedEdgeSegment
|
|
979
|
+
end={end}
|
|
980
|
+
key={`highlight-edge-${edgeIndex}`}
|
|
981
|
+
start={start}
|
|
982
|
+
y={edgeHandleY}
|
|
983
|
+
/>
|
|
984
|
+
)
|
|
985
|
+
})}
|
|
382
986
|
|
|
383
|
-
{/* Vertex handles - blue cylinders that match surface height
|
|
987
|
+
{/* Vertex handles - blue cylinders that match surface height. During a
|
|
988
|
+
drag only the active handle stays mounted so the gesture reads
|
|
989
|
+
clearly (same rule for the cross / edge handles below). */}
|
|
384
990
|
{displayPolygon.map(([x, z], index) => {
|
|
385
991
|
const isHovered = hoveredVertex === index
|
|
386
992
|
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
|
|
387
|
-
|
|
993
|
+
if (dragState && !isDragging) return null
|
|
994
|
+
const isLinkedHighlighted = isVertexLinkedHighlighted(index)
|
|
995
|
+
const isHighlighted = isDragging || isHovered || isLinkedHighlighted
|
|
996
|
+
const radius = 0.08
|
|
388
997
|
const height = handleHeight
|
|
998
|
+
const point: [number, number] = [x!, z!]
|
|
999
|
+
const position: [number, number, number] = [x!, editY + height / 2, z!]
|
|
1000
|
+
const handleProps: PolygonHandleHandlers = {
|
|
1001
|
+
onClick: (e) => {
|
|
1002
|
+
if (e.button !== 0) return
|
|
1003
|
+
e.stopPropagation()
|
|
1004
|
+
},
|
|
1005
|
+
onDoubleClick: (e) => {
|
|
1006
|
+
if (e.button !== 0) return
|
|
1007
|
+
e.stopPropagation()
|
|
1008
|
+
if (canDelete) {
|
|
1009
|
+
handleDeleteVertex(index)
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
onPointerDown: (e) => {
|
|
1013
|
+
if (e.button !== 0) return
|
|
1014
|
+
stopHandlePointerDown(e)
|
|
1015
|
+
setHoveredEdge(null)
|
|
1016
|
+
onBeforeVertexDrag?.(index, point)
|
|
1017
|
+
startDrag({
|
|
1018
|
+
isDragging: true,
|
|
1019
|
+
mode: 'vertex',
|
|
1020
|
+
vertexIndex: index,
|
|
1021
|
+
initialPosition: [x!, z!],
|
|
1022
|
+
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
1023
|
+
pointerId: e.pointerId,
|
|
1024
|
+
})
|
|
1025
|
+
},
|
|
1026
|
+
onPointerEnter: (e) => {
|
|
1027
|
+
e.stopPropagation()
|
|
1028
|
+
setHoveredVertex(index)
|
|
1029
|
+
setBodyCursor('move')
|
|
1030
|
+
},
|
|
1031
|
+
onPointerLeave: (e) => {
|
|
1032
|
+
e.stopPropagation()
|
|
1033
|
+
setHoveredVertex(null)
|
|
1034
|
+
// Keep the cursor for the whole gesture when the pointer slips
|
|
1035
|
+
// off the handle mid-drag; commit clears it.
|
|
1036
|
+
if (!dragState?.isDragging) clearBodyCursor()
|
|
1037
|
+
},
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (renderVertexHandle) {
|
|
1041
|
+
return (
|
|
1042
|
+
<Fragment key={`vertex-${index}`}>
|
|
1043
|
+
{renderVertexHandle({
|
|
1044
|
+
canDelete,
|
|
1045
|
+
handleProps,
|
|
1046
|
+
height,
|
|
1047
|
+
index,
|
|
1048
|
+
isDragging,
|
|
1049
|
+
isHovered,
|
|
1050
|
+
point,
|
|
1051
|
+
position,
|
|
1052
|
+
radius,
|
|
1053
|
+
})}
|
|
1054
|
+
</Fragment>
|
|
1055
|
+
)
|
|
1056
|
+
}
|
|
389
1057
|
|
|
390
1058
|
return (
|
|
391
|
-
<
|
|
392
|
-
|
|
1059
|
+
<OutlinedCylinderHandle
|
|
1060
|
+
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
|
1061
|
+
height={height}
|
|
393
1062
|
key={`vertex-${index}`}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}}
|
|
399
|
-
onDoubleClick={(e) => {
|
|
400
|
-
if (e.button !== 0) return
|
|
401
|
-
e.stopPropagation()
|
|
402
|
-
if (canDelete) {
|
|
403
|
-
handleDeleteVertex(index)
|
|
404
|
-
}
|
|
405
|
-
}}
|
|
406
|
-
onPointerDown={(e) => {
|
|
407
|
-
if (e.button !== 0) return
|
|
408
|
-
e.stopPropagation()
|
|
409
|
-
setHoveredEdge(null)
|
|
410
|
-
setDragState({
|
|
411
|
-
isDragging: true,
|
|
412
|
-
mode: 'vertex',
|
|
413
|
-
vertexIndex: index,
|
|
414
|
-
initialPosition: [x!, z!],
|
|
415
|
-
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
416
|
-
pointerId: e.pointerId,
|
|
417
|
-
})
|
|
418
|
-
}}
|
|
419
|
-
onPointerEnter={(e) => {
|
|
420
|
-
e.stopPropagation()
|
|
421
|
-
setHoveredVertex(index)
|
|
422
|
-
}}
|
|
423
|
-
onPointerLeave={(e) => {
|
|
424
|
-
e.stopPropagation()
|
|
425
|
-
setHoveredVertex(null)
|
|
426
|
-
}}
|
|
427
|
-
position={[x!, editY + height / 2, z!]}
|
|
428
|
-
>
|
|
429
|
-
<cylinderGeometry args={[radius, radius, height, 16]} />
|
|
430
|
-
<meshStandardMaterial
|
|
431
|
-
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
|
|
432
|
-
/>
|
|
433
|
-
</mesh>
|
|
1063
|
+
{...handleProps}
|
|
1064
|
+
position={position}
|
|
1065
|
+
radius={radius}
|
|
1066
|
+
/>
|
|
434
1067
|
)
|
|
435
1068
|
})}
|
|
436
1069
|
|
|
437
|
-
{allowPolygonMove && (
|
|
438
|
-
<
|
|
439
|
-
|
|
440
|
-
layers={EDITOR_LAYER}
|
|
1070
|
+
{allowPolygonMove && (!dragState || dragState.mode === 'polygon') && (
|
|
1071
|
+
<OutlinedCrossHandle
|
|
1072
|
+
color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
|
441
1073
|
onClick={(e) => {
|
|
442
1074
|
if (e.button !== 0) return
|
|
443
1075
|
e.stopPropagation()
|
|
444
1076
|
}}
|
|
445
1077
|
onPointerDown={(e) => {
|
|
446
1078
|
if (e.button !== 0) return
|
|
447
|
-
e
|
|
1079
|
+
stopHandlePointerDown(e)
|
|
448
1080
|
setHoveredEdge(null)
|
|
449
|
-
|
|
1081
|
+
startDrag({
|
|
450
1082
|
isDragging: true,
|
|
451
1083
|
mode: 'polygon',
|
|
452
1084
|
vertexIndex: null,
|
|
@@ -455,117 +1087,176 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
455
1087
|
pointerId: e.pointerId,
|
|
456
1088
|
})
|
|
457
1089
|
}}
|
|
1090
|
+
onPointerEnter={(e) => {
|
|
1091
|
+
e.stopPropagation()
|
|
1092
|
+
setBodyCursor('move')
|
|
1093
|
+
}}
|
|
1094
|
+
onPointerLeave={(e) => {
|
|
1095
|
+
e.stopPropagation()
|
|
1096
|
+
if (!dragState?.isDragging) clearBodyCursor()
|
|
1097
|
+
}}
|
|
458
1098
|
position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
|
|
459
|
-
|
|
460
|
-
<sphereGeometry args={[0.09, 20, 20]} />
|
|
461
|
-
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
|
|
462
|
-
</mesh>
|
|
1099
|
+
/>
|
|
463
1100
|
)}
|
|
464
|
-
|
|
465
1101
|
{allowEdgeMove &&
|
|
466
|
-
edgeHandles.map(({ index, length, midpoint, rotationY }) => {
|
|
1102
|
+
edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
|
|
467
1103
|
const isHovered = hoveredEdge === index
|
|
468
1104
|
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
|
|
1105
|
+
if (dragState && !isDragging) return null
|
|
1106
|
+
const isLinkedHighlighted = highlightedEdgeIndices.has(index)
|
|
1107
|
+
const isHighlighted = isDragging || isHovered || isLinkedHighlighted
|
|
1108
|
+
const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
|
|
1109
|
+
const arrowZ = midpoint[1] + outwardNormal[1] * EDGE_ARROW_OFFSET
|
|
1110
|
+
|
|
1111
|
+
const beginEdgeDrag = (e: { button: number; pointerId: number }) => {
|
|
1112
|
+
const start = displayPolygon[index]
|
|
1113
|
+
const end = displayPolygon[(index + 1) % displayPolygon.length]
|
|
1114
|
+
if (!(start && end)) return
|
|
1115
|
+
|
|
1116
|
+
const edgeNormal = getEdgeNormal(start, end)
|
|
1117
|
+
if (!edgeNormal) return
|
|
1118
|
+
|
|
1119
|
+
setHoveredEdge(null)
|
|
1120
|
+
startDrag({
|
|
1121
|
+
isDragging: true,
|
|
1122
|
+
mode: 'edge',
|
|
1123
|
+
vertexIndex: null,
|
|
1124
|
+
edgeIndex: index,
|
|
1125
|
+
edgeNormal,
|
|
1126
|
+
initialPosition: cursorPosition,
|
|
1127
|
+
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
1128
|
+
pointerId: e.pointerId,
|
|
1129
|
+
})
|
|
1130
|
+
}
|
|
469
1131
|
|
|
470
1132
|
return (
|
|
471
|
-
<
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1133
|
+
<group key={`edge-${index}`}>
|
|
1134
|
+
{/* Edge bar — VISUAL ONLY (raycast disabled). It runs the full
|
|
1135
|
+
length of the edge and overlaps the vertex/midpoint handles at
|
|
1136
|
+
its ends + centre, so making it pickable let edges steal those
|
|
1137
|
+
clicks. Edge dragging runs through the chevron arrow below,
|
|
1138
|
+
which sits outside the polygon and never overlaps a
|
|
1139
|
+
vertex/midpoint handle. */}
|
|
1140
|
+
<mesh
|
|
1141
|
+
geometry={EDGE_HANDLE_GEOMETRY}
|
|
1142
|
+
layers={EDITOR_LAYER}
|
|
1143
|
+
position={[midpoint[0], edgeHandleY, midpoint[1]]}
|
|
1144
|
+
raycast={NO_RAYCAST}
|
|
1145
|
+
rotation={[0, rotationY, 0]}
|
|
1146
|
+
scale={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]}
|
|
1147
|
+
>
|
|
1148
|
+
<meshBasicMaterial
|
|
1149
|
+
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
|
1150
|
+
opacity={isDragging ? 0.5 : isHighlighted ? 0.38 : 0.14}
|
|
1151
|
+
transparent
|
|
1152
|
+
/>
|
|
1153
|
+
</mesh>
|
|
1154
|
+
{/* Per-side resize arrow — the interactive edge-drag handle.
|
|
1155
|
+
Points outward from the edge; dragging it translates only this
|
|
1156
|
+
edge's two vertices along the outward normal. */}
|
|
1157
|
+
<OutlinedEdgeArrowHandle
|
|
1158
|
+
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
|
1159
|
+
geometry={arrowGeometry}
|
|
1160
|
+
onClick={(e) => {
|
|
1161
|
+
if (e.button !== 0) return
|
|
1162
|
+
e.stopPropagation()
|
|
1163
|
+
}}
|
|
1164
|
+
onPointerDown={(e) => {
|
|
1165
|
+
if (e.button !== 0) return
|
|
1166
|
+
stopHandlePointerDown(e)
|
|
1167
|
+
beginEdgeDrag(e)
|
|
1168
|
+
}}
|
|
1169
|
+
onPointerEnter={(e) => {
|
|
1170
|
+
e.stopPropagation()
|
|
1171
|
+
setHoveredEdge(index)
|
|
1172
|
+
setBodyCursor(edgeResizeCursor(midpoint, outwardNormal))
|
|
1173
|
+
}}
|
|
1174
|
+
onPointerLeave={(e) => {
|
|
1175
|
+
e.stopPropagation()
|
|
1176
|
+
setHoveredEdge(null)
|
|
1177
|
+
if (!dragState?.isDragging) clearBodyCursor()
|
|
1178
|
+
}}
|
|
1179
|
+
position={[arrowX, edgeHandleY, arrowZ]}
|
|
1180
|
+
rotationY={outwardAngle}
|
|
1181
|
+
scale={EDGE_ARROW_SCALE}
|
|
516
1182
|
/>
|
|
517
|
-
</
|
|
1183
|
+
</group>
|
|
518
1184
|
)
|
|
519
1185
|
})}
|
|
520
1186
|
|
|
521
1187
|
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
|
522
|
-
{
|
|
1188
|
+
{showMidpointHandles &&
|
|
1189
|
+
!dragState &&
|
|
523
1190
|
midpoints.map(([x, z], index) => {
|
|
524
1191
|
const isHovered = hoveredMidpoint === index
|
|
525
|
-
const
|
|
1192
|
+
const isLinkedHighlighted = highlightedEdgeIndices.has(index)
|
|
1193
|
+
const isHighlighted = isHovered || isLinkedHighlighted
|
|
1194
|
+
const radius = 0.05
|
|
526
1195
|
const height = handleHeight
|
|
1196
|
+
const point: [number, number] = [x!, z!]
|
|
1197
|
+
const position: [number, number, number] = [x!, editY + height / 2, z!]
|
|
1198
|
+
const handleProps: PolygonHandleHandlers = {
|
|
1199
|
+
onClick: (e) => {
|
|
1200
|
+
if (e.button !== 0) return
|
|
1201
|
+
e.stopPropagation()
|
|
1202
|
+
},
|
|
1203
|
+
onPointerDown: (e) => {
|
|
1204
|
+
if (e.button !== 0) return
|
|
1205
|
+
stopHandlePointerDown(e)
|
|
1206
|
+
onBeforeVertexDrag?.(index + 1, point)
|
|
1207
|
+
const insertedVertex = handleAddVertex(index, point)
|
|
1208
|
+
if (insertedVertex.vertexIndex >= 0) {
|
|
1209
|
+
startDrag({
|
|
1210
|
+
isDragging: true,
|
|
1211
|
+
mode: 'vertex',
|
|
1212
|
+
vertexIndex: insertedVertex.vertexIndex,
|
|
1213
|
+
initialPosition: point,
|
|
1214
|
+
initialPolygon: insertedVertex.polygon,
|
|
1215
|
+
pointerId: e.pointerId,
|
|
1216
|
+
})
|
|
1217
|
+
setHoveredMidpoint(null)
|
|
1218
|
+
}
|
|
1219
|
+
},
|
|
1220
|
+
onPointerEnter: (e) => {
|
|
1221
|
+
e.stopPropagation()
|
|
1222
|
+
setHoveredMidpoint(index)
|
|
1223
|
+
setBodyCursor('move')
|
|
1224
|
+
},
|
|
1225
|
+
onPointerLeave: (e) => {
|
|
1226
|
+
e.stopPropagation()
|
|
1227
|
+
setHoveredMidpoint(null)
|
|
1228
|
+
clearBodyCursor()
|
|
1229
|
+
},
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (renderMidpointHandle) {
|
|
1233
|
+
return (
|
|
1234
|
+
<Fragment key={`midpoint-${index}`}>
|
|
1235
|
+
{renderMidpointHandle({
|
|
1236
|
+
handleProps,
|
|
1237
|
+
height,
|
|
1238
|
+
index,
|
|
1239
|
+
isHovered,
|
|
1240
|
+
point,
|
|
1241
|
+
position,
|
|
1242
|
+
radius,
|
|
1243
|
+
})}
|
|
1244
|
+
</Fragment>
|
|
1245
|
+
)
|
|
1246
|
+
}
|
|
527
1247
|
|
|
528
1248
|
return (
|
|
529
|
-
<
|
|
1249
|
+
<OutlinedCylinderHandle
|
|
1250
|
+
// Midpoints read as secondary add-points: the brighter hover
|
|
1251
|
+
// shade at rest distinguishes them from the vertex handles.
|
|
1252
|
+
color={EDGE_ARROW_HOVER_COLOR}
|
|
1253
|
+
height={height}
|
|
530
1254
|
key={`midpoint-${index}`}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
onPointerDown={(e) => {
|
|
537
|
-
if (e.button !== 0) return
|
|
538
|
-
e.stopPropagation()
|
|
539
|
-
const insertedVertex = handleAddVertex(index, [x!, z!])
|
|
540
|
-
if (insertedVertex.vertexIndex >= 0) {
|
|
541
|
-
setDragState({
|
|
542
|
-
isDragging: true,
|
|
543
|
-
mode: 'vertex',
|
|
544
|
-
vertexIndex: insertedVertex.vertexIndex,
|
|
545
|
-
initialPosition: [x!, z!],
|
|
546
|
-
initialPolygon: insertedVertex.polygon,
|
|
547
|
-
pointerId: e.pointerId,
|
|
548
|
-
})
|
|
549
|
-
setHoveredMidpoint(null)
|
|
550
|
-
}
|
|
551
|
-
}}
|
|
552
|
-
onPointerEnter={(e) => {
|
|
553
|
-
e.stopPropagation()
|
|
554
|
-
setHoveredMidpoint(index)
|
|
555
|
-
}}
|
|
556
|
-
onPointerLeave={(e) => {
|
|
557
|
-
e.stopPropagation()
|
|
558
|
-
setHoveredMidpoint(null)
|
|
559
|
-
}}
|
|
560
|
-
position={[x!, editY + height / 2, z!]}
|
|
561
|
-
>
|
|
562
|
-
<cylinderGeometry args={[radius, radius, height, 16]} />
|
|
563
|
-
<meshStandardMaterial
|
|
564
|
-
color={isHovered ? '#4ade80' : '#22c55e'}
|
|
565
|
-
opacity={isHovered ? 1 : 0.7}
|
|
566
|
-
transparent
|
|
567
|
-
/>
|
|
568
|
-
</mesh>
|
|
1255
|
+
{...handleProps}
|
|
1256
|
+
opacity={isHighlighted ? 1 : 0.7}
|
|
1257
|
+
position={position}
|
|
1258
|
+
radius={radius}
|
|
1259
|
+
/>
|
|
569
1260
|
)
|
|
570
1261
|
})}
|
|
571
1262
|
</group>
|