@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,41 +1,1836 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
getActiveRoofHeight,
|
|
5
|
+
getDutchRoofMetrics,
|
|
6
|
+
getEffectiveNode,
|
|
7
|
+
getRoofSegmentVisibleTopBounds,
|
|
8
|
+
getSegmentSlopeFrame,
|
|
9
|
+
MIN_ROOF_SEGMENT_TRIM_SPAN,
|
|
10
|
+
nodeRegistry,
|
|
11
|
+
normalizeRoofSegmentTrim,
|
|
12
|
+
ROOF_SHAPE_DEFAULTS,
|
|
13
|
+
type RoofNode,
|
|
14
|
+
type RoofSegmentNode,
|
|
15
|
+
type RoofSegmentTrim,
|
|
16
|
+
sceneRegistry,
|
|
17
|
+
useLiveNodeOverrides,
|
|
18
|
+
useScene,
|
|
19
|
+
} from '@pascal-app/core'
|
|
20
|
+
import {
|
|
21
|
+
Brush,
|
|
22
|
+
csgEvaluator,
|
|
23
|
+
generateRoofSegmentGeometry,
|
|
24
|
+
INTERSECTION,
|
|
25
|
+
prepareBrushForCSG,
|
|
26
|
+
useViewer,
|
|
27
|
+
} from '@pascal-app/viewer'
|
|
28
|
+
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
|
29
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
30
|
+
import * as THREE from 'three'
|
|
31
|
+
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
32
|
+
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
33
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
34
|
+
import { isHistoryShortcut } from '../../../lib/history'
|
|
35
|
+
import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy'
|
|
36
|
+
import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope'
|
|
37
|
+
import { swallowNextClick } from '../../editor/handles/use-handle-drag'
|
|
38
|
+
|
|
39
|
+
// Empty placeholder geometry used when we reveal segments-wrapper for
|
|
40
|
+
// accessory editing. The roof's CSG-merged shell is the only thing
|
|
41
|
+
// that should render the roof surface in this mode — the per-segment
|
|
42
|
+
// CSG geometry (if any was left over from a prior edit) would visually
|
|
43
|
+
// double the cut shape, so we strip each segment mesh back to nothing.
|
|
44
|
+
// `RoofSystem` rebuilds CSG on demand if the user later selects a
|
|
45
|
+
// segment, so destroying the cached geometry here only costs one
|
|
46
|
+
// recomputation per segment when the user actually wants it back.
|
|
47
|
+
function makeEmptySegmentGeometry(): THREE.BufferGeometry {
|
|
48
|
+
const g = new THREE.BufferGeometry()
|
|
49
|
+
// Three zero-vertices (one degenerate, invisible triangle), not an empty
|
|
50
|
+
// attribute: in accessory-reveal mode the segments-wrapper is shown, so these
|
|
51
|
+
// meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer
|
|
52
|
+
// slot 0 unbound and the draw is rejected, poisoning the command encoder.
|
|
53
|
+
g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
|
54
|
+
g.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
|
55
|
+
g.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
|
56
|
+
g.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
|
57
|
+
// Match the four material slots the roof-segment renderer's material
|
|
58
|
+
// array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
|
|
59
|
+
// groups, mesh.material is a single-material lookup that mismatches
|
|
60
|
+
// the array — same crash mode the BoxGeometry workaround in
|
|
61
|
+
// `roof-system.tsx:144` guards against.
|
|
62
|
+
g.addGroup(0, 0, 0)
|
|
63
|
+
g.addGroup(0, 0, 1)
|
|
64
|
+
g.addGroup(0, 0, 2)
|
|
65
|
+
g.addGroup(0, 0, 3)
|
|
66
|
+
return g
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type RoofTrimSide =
|
|
70
|
+
| 'left'
|
|
71
|
+
| 'right'
|
|
72
|
+
| 'front'
|
|
73
|
+
| 'back'
|
|
74
|
+
| 'frontLeft'
|
|
75
|
+
| 'frontRight'
|
|
76
|
+
| 'backLeft'
|
|
77
|
+
| 'backRight'
|
|
78
|
+
| 'frontLeftX'
|
|
79
|
+
| 'frontLeftZ'
|
|
80
|
+
| 'frontRightX'
|
|
81
|
+
| 'frontRightZ'
|
|
82
|
+
| 'backLeftX'
|
|
83
|
+
| 'backLeftZ'
|
|
84
|
+
| 'backRightX'
|
|
85
|
+
| 'backRightZ'
|
|
86
|
+
|
|
87
|
+
type DiagonalTrimSide = 'frontLeft' | 'frontRight' | 'backLeft' | 'backRight'
|
|
88
|
+
type DiagonalTrimAxisSide = Exclude<
|
|
89
|
+
RoofTrimSide,
|
|
90
|
+
'left' | 'right' | 'front' | 'back' | DiagonalTrimSide
|
|
91
|
+
>
|
|
92
|
+
type DiagonalTrimAxisKey = DiagonalTrimAxisSide
|
|
93
|
+
|
|
94
|
+
const TRIM_PLANE_COLOR = '#93c5fd'
|
|
95
|
+
const TRIM_PLANE_OPACITY = 0.18
|
|
96
|
+
const TRIM_PLANE_HOVER_OPACITY = 0.32
|
|
97
|
+
const TRIM_RAIL_COLOR = '#2563eb'
|
|
98
|
+
const TRIM_RAIL_HOVER_COLOR = '#4f46e5'
|
|
99
|
+
const TRIM_CAP_COLOR = TRIM_RAIL_COLOR
|
|
100
|
+
const TRIM_CAP_HOVER_COLOR = TRIM_RAIL_HOVER_COLOR
|
|
101
|
+
const TRIM_ADD_COLOR = TRIM_RAIL_COLOR
|
|
102
|
+
const TRIM_ADD_HOVER_COLOR = TRIM_RAIL_HOVER_COLOR
|
|
103
|
+
const TRIM_PLANE_RENDER_ORDER = 1001
|
|
104
|
+
const TRIM_RAIL_RENDER_ORDER = 1003
|
|
105
|
+
const TRIM_HANDLE_BASE_SCALE = 0.65
|
|
106
|
+
const TRIM_RAIL_SURFACE_OFFSET = 0
|
|
107
|
+
const TRIM_RAIL_HIT_HEIGHT = 0.18
|
|
108
|
+
const TRIM_RAIL_HIT_DEPTH = 0.16
|
|
109
|
+
const TRIM_CAP_HIT_SIZE = 0.22
|
|
110
|
+
const TRIM_LIVE_REBUILD_INTERVAL_MS = 80
|
|
111
|
+
|
|
112
|
+
const TRIM_UNIT_PLANE_GEOMETRY = new THREE.PlaneGeometry(1, 1)
|
|
113
|
+
const TRIM_UNIT_RAIL_GEOMETRY = new THREE.BoxGeometry(1, 1, 1)
|
|
114
|
+
const TRIM_UNIT_RAIL_CAP_GEOMETRY = new THREE.SphereGeometry(0.5, 16, 8)
|
|
115
|
+
const TRIM_UNIT_ADD_GEOMETRY = new THREE.OctahedronGeometry(0.5, 0)
|
|
116
|
+
|
|
117
|
+
const trimPlaneMaterial = new MeshBasicNodeMaterial({
|
|
118
|
+
color: TRIM_PLANE_COLOR,
|
|
119
|
+
depthTest: false,
|
|
120
|
+
depthWrite: false,
|
|
121
|
+
opacity: TRIM_PLANE_OPACITY,
|
|
122
|
+
side: THREE.DoubleSide,
|
|
123
|
+
transparent: true,
|
|
124
|
+
})
|
|
125
|
+
const trimPlaneHoverMaterial = new MeshBasicNodeMaterial({
|
|
126
|
+
color: TRIM_PLANE_COLOR,
|
|
127
|
+
depthTest: false,
|
|
128
|
+
depthWrite: false,
|
|
129
|
+
opacity: TRIM_PLANE_HOVER_OPACITY,
|
|
130
|
+
side: THREE.DoubleSide,
|
|
131
|
+
transparent: true,
|
|
132
|
+
})
|
|
133
|
+
const trimRailMaterial = new MeshBasicNodeMaterial({
|
|
134
|
+
color: TRIM_RAIL_COLOR,
|
|
135
|
+
depthTest: false,
|
|
136
|
+
depthWrite: false,
|
|
137
|
+
})
|
|
138
|
+
const trimRailHoverMaterial = new MeshBasicNodeMaterial({
|
|
139
|
+
color: TRIM_RAIL_HOVER_COLOR,
|
|
140
|
+
depthTest: false,
|
|
141
|
+
depthWrite: false,
|
|
142
|
+
})
|
|
143
|
+
const trimCapMaterial = new MeshBasicNodeMaterial({
|
|
144
|
+
color: TRIM_CAP_COLOR,
|
|
145
|
+
depthTest: false,
|
|
146
|
+
depthWrite: false,
|
|
147
|
+
opacity: 1,
|
|
148
|
+
transparent: false,
|
|
149
|
+
})
|
|
150
|
+
const trimCapHoverMaterial = new MeshBasicNodeMaterial({
|
|
151
|
+
color: TRIM_CAP_HOVER_COLOR,
|
|
152
|
+
depthTest: false,
|
|
153
|
+
depthWrite: false,
|
|
154
|
+
opacity: 1,
|
|
155
|
+
transparent: false,
|
|
156
|
+
})
|
|
157
|
+
const trimAddMaterial = new MeshBasicNodeMaterial({
|
|
158
|
+
color: TRIM_ADD_COLOR,
|
|
159
|
+
depthTest: false,
|
|
160
|
+
depthWrite: false,
|
|
161
|
+
})
|
|
162
|
+
const trimAddHoverMaterial = new MeshBasicNodeMaterial({
|
|
163
|
+
color: TRIM_ADD_HOVER_COLOR,
|
|
164
|
+
depthTest: false,
|
|
165
|
+
depthWrite: false,
|
|
166
|
+
})
|
|
167
|
+
const trimDiagonalPreviewRailMaterial = new MeshBasicNodeMaterial({
|
|
168
|
+
color: TRIM_RAIL_COLOR,
|
|
169
|
+
depthTest: false,
|
|
170
|
+
depthWrite: false,
|
|
171
|
+
opacity: 0.42,
|
|
172
|
+
transparent: true,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// ─── Section-cut (cutaway) feedback ──────────────────────────────────
|
|
176
|
+
// The cross-section the trim removes: a thin slab at each cut line is
|
|
177
|
+
// intersected with the untrimmed roof shell AND every hosted accessory
|
|
178
|
+
// (chimney, vents, skylight, dormer, …), so the fill shows real material only
|
|
179
|
+
// (wall + deck bands + any accessory the cut passes through) and leaves the
|
|
180
|
+
// hollow attic empty. The outline is the edge silhouette of that fill. A
|
|
181
|
+
// translucent red fill + darker red outline read together as a SketchUp-style
|
|
182
|
+
// section cut of what the trim removes.
|
|
183
|
+
// Matches the app's destructive red (`--destructive`, oklch(0.577 0.245 27.325)
|
|
184
|
+
// ≈ #dc2626 / red-600 — the delete/destructive button color). Three's
|
|
185
|
+
// MeshBasicNodeMaterial color doesn't parse oklch() strings, so use the sRGB hex
|
|
186
|
+
// equivalent; the outline is a darker shade of the same hue.
|
|
187
|
+
const SECTION_FILL_COLOR = '#dc2626'
|
|
188
|
+
const SECTION_OUTLINE_COLOR = '#991b1b'
|
|
189
|
+
const SECTION_FILL_RENDER_ORDER = 1000
|
|
190
|
+
const SECTION_OUTLINE_RENDER_ORDER = 1002
|
|
191
|
+
// Build the cut line just inside the kept material so the section plane never
|
|
192
|
+
// sits coplanar with the mesh's own cut face.
|
|
193
|
+
const SECTION_PLANE_INSET = 0.004
|
|
194
|
+
|
|
195
|
+
// A vertical cut plane defined by a horizontal line through the XZ ground
|
|
196
|
+
// plane. `origin` is a point on the line, `dir` is the unit in-plane
|
|
197
|
+
// horizontal direction (in XZ), and `normal` is the unit XZ normal. Vertices
|
|
198
|
+
// are projected to (u = dir·xz, v = world Y) for slicing and lifted back via
|
|
199
|
+
// `origin + dir`. This handles both the axis-aligned sides (left/right cut on
|
|
200
|
+
// x, front/back on z) and the angled diagonal/corner cuts.
|
|
201
|
+
type SectionPlaneSpec = {
|
|
202
|
+
origin: THREE.Vector2 // point on the cut line, (x, z)
|
|
203
|
+
dir: THREE.Vector2 // unit in-plane horizontal direction, (x, z)
|
|
204
|
+
normal: THREE.Vector2 // unit normal in the XZ plane, (x, z)
|
|
205
|
+
// The cut plane is infinite, but the visible cut only spans the footprint
|
|
206
|
+
// edge between the two endpoints. We clip slice segments to this u-range
|
|
207
|
+
// (u = projection along `dir`) so the silhouette doesn't sprout lines where
|
|
208
|
+
// the infinite plane grazes the rest of the roof.
|
|
209
|
+
uMin: number
|
|
210
|
+
uMax: number
|
|
211
|
+
// How far the slab extends past each end of the cut line. A FREE (untrimmed)
|
|
212
|
+
// end has eave/overhang material beyond the footprint edge, so we extend to
|
|
213
|
+
// capture it; a TRIMMED end has none, so we clamp to 0 — otherwise the slab
|
|
214
|
+
// grabs phantom material from the untrimmed shell and the red section pokes
|
|
215
|
+
// out past the trim box. `dir` points A→B, so uMin is always endpoint A and
|
|
216
|
+
// uMax endpoint B; `extendMin` applies at A, `extendMax` at B.
|
|
217
|
+
extendMin: number
|
|
218
|
+
extendMax: number
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Build a section plane from two XZ points on the cut line. `inset` shifts the
|
|
222
|
+
// plane along its normal toward the supplied "inside" point so the slice sits
|
|
223
|
+
// just inside kept material instead of coplanar with the mesh's own cut face.
|
|
224
|
+
// `extendA` / `extendB` set the slab overrun past endpoint A / B (0 at trimmed
|
|
225
|
+
// ends, a small overhang allowance at free ends).
|
|
226
|
+
function makeSectionPlane(
|
|
227
|
+
ax: number,
|
|
228
|
+
az: number,
|
|
229
|
+
bx: number,
|
|
230
|
+
bz: number,
|
|
231
|
+
inset = 0,
|
|
232
|
+
insidePoint?: readonly [number, number],
|
|
233
|
+
extendA = 0,
|
|
234
|
+
extendB = 0,
|
|
235
|
+
): SectionPlaneSpec {
|
|
236
|
+
const dir = new THREE.Vector2(bx - ax, bz - az)
|
|
237
|
+
if (dir.lengthSq() < 1e-12) dir.set(1, 0)
|
|
238
|
+
dir.normalize()
|
|
239
|
+
const normal = new THREE.Vector2(dir.y, -dir.x)
|
|
240
|
+
const origin = new THREE.Vector2(ax, az)
|
|
241
|
+
if (inset !== 0 && insidePoint) {
|
|
242
|
+
const toInsideX = insidePoint[0] - ax
|
|
243
|
+
const toInsideZ = insidePoint[1] - az
|
|
244
|
+
const sign = normal.x * toInsideX + normal.y * toInsideZ >= 0 ? 1 : -1
|
|
245
|
+
origin.x += normal.x * inset * sign
|
|
246
|
+
origin.y += normal.y * inset * sign
|
|
247
|
+
}
|
|
248
|
+
// dir = (B-A).normalized, so dir·B - dir·A = |B-A| > 0 → uB is always uMax.
|
|
249
|
+
const uA = dir.x * ax + dir.y * az
|
|
250
|
+
const uB = dir.x * bx + dir.y * bz
|
|
251
|
+
return { origin, dir, normal, uMin: uA, uMax: uB, extendMin: extendA, extendMax: extendB }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Lift a 2D plane-frame point (u = projection along dir, v = world Y) back to
|
|
255
|
+
// segment-local 3D using the plane's origin and direction.
|
|
256
|
+
function liftSectionPoint(plane: SectionPlaneSpec, u: number, v: number): [number, number, number] {
|
|
257
|
+
const uOrigin = plane.dir.x * plane.origin.x + plane.dir.y * plane.origin.y
|
|
258
|
+
const t = u - uOrigin
|
|
259
|
+
return [plane.origin.x + plane.dir.x * t, v, plane.origin.y + plane.dir.y * t]
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const sectionFillMaterial = new MeshBasicNodeMaterial({
|
|
263
|
+
color: SECTION_FILL_COLOR,
|
|
264
|
+
depthTest: false,
|
|
265
|
+
depthWrite: false,
|
|
266
|
+
opacity: 0.85,
|
|
267
|
+
side: THREE.DoubleSide,
|
|
268
|
+
transparent: true,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const sectionOutlineMaterial = new LineBasicNodeMaterial({
|
|
272
|
+
color: SECTION_OUTLINE_COLOR,
|
|
273
|
+
depthTest: false,
|
|
274
|
+
depthWrite: false,
|
|
275
|
+
linewidth: 2,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const hoverOutlineProxyMaterial = new THREE.MeshBasicMaterial({
|
|
279
|
+
colorWrite: false,
|
|
280
|
+
depthWrite: false,
|
|
281
|
+
side: THREE.DoubleSide,
|
|
282
|
+
transparent: true,
|
|
283
|
+
opacity: 0,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// Half-thickness of the slab brush intersected with the roof shell. The wafer
|
|
287
|
+
// must be thin enough to read as a flat cut face but thick enough that CSG
|
|
288
|
+
// produces a stable, non-degenerate solid.
|
|
289
|
+
const SECTION_SLAB_HALF_THICKNESS = 0.006
|
|
290
|
+
|
|
291
|
+
// Builds a thin oriented slab brush straddling one cut line, spanning the full
|
|
292
|
+
// segment height. Intersecting it with the roof shell yields exactly the
|
|
293
|
+
// material the cut passes through (roof slab + wall bands), leaving the hollow
|
|
294
|
+
// attic empty — a true section cut.
|
|
295
|
+
function buildSectionSlabBrush(plane: SectionPlaneSpec, vMin: number, vMax: number): Brush | null {
|
|
296
|
+
const span = plane.uMax - plane.uMin
|
|
297
|
+
const height = vMax - vMin
|
|
298
|
+
if (!(span > 1e-4 && height > 1e-4)) return null
|
|
299
|
+
|
|
300
|
+
// Extend past each end only as far as that end allows: a free edge gets an
|
|
301
|
+
// overhang allowance so eave/shingle material is captured; a trimmed end gets
|
|
302
|
+
// 0 so the slab stops at the cut line and the section never pokes past the
|
|
303
|
+
// trim box. The box is centred on the extended span's midpoint.
|
|
304
|
+
const uLo = plane.uMin - plane.extendMin
|
|
305
|
+
const uHi = plane.uMax + plane.extendMax
|
|
306
|
+
const length = uHi - uLo
|
|
307
|
+
const geometry = new THREE.BoxGeometry(length, height, SECTION_SLAB_HALF_THICKNESS * 2)
|
|
308
|
+
const yaw = Math.atan2(-plane.dir.y, plane.dir.x)
|
|
309
|
+
geometry.rotateY(yaw)
|
|
310
|
+
const midU = (uLo + uHi) / 2
|
|
311
|
+
const [cx, , cz] = liftSectionPoint(plane, midU, 0)
|
|
312
|
+
geometry.translate(cx, (vMin + vMax) / 2, cz)
|
|
313
|
+
|
|
314
|
+
const brush = new Brush(geometry)
|
|
315
|
+
prepareBrushForCSG(brush)
|
|
316
|
+
return brush
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Builds the combined fill + outline geometries (segment-local 3D) for all
|
|
320
|
+
// active trim planes. The fill is the CSG intersection of the untrimmed roof
|
|
321
|
+
// shell with a thin slab at each cut line (material only — attic stays hollow).
|
|
322
|
+
// The outline is the edge silhouette of that same fill (via EdgesGeometry), so
|
|
323
|
+
// it traces the real cut shape — wall/deck band boundaries and the hollow-attic
|
|
324
|
+
// edge — instead of just the top surface line. Both in segment-local space, to
|
|
325
|
+
// be mounted under the segment-world-matrix group.
|
|
326
|
+
function buildSectionGeometries(
|
|
327
|
+
segment: RoofSegmentNode,
|
|
328
|
+
planes: SectionPlaneSpec[],
|
|
329
|
+
accessoryGeometries: THREE.BufferGeometry[] = [],
|
|
330
|
+
): { fill: THREE.BufferGeometry; outline: THREE.BufferGeometry } | null {
|
|
331
|
+
if (planes.length === 0) return null
|
|
332
|
+
|
|
333
|
+
// Untrimmed shell — the source we intersect slabs against. Dutch rake boards
|
|
334
|
+
// are decorative overhang geometry; slicing them makes the red preview sprout
|
|
335
|
+
// tall phantom triangles, so the section fill uses the stable roof shell.
|
|
336
|
+
const sectionSourceSegment: RoofSegmentNode =
|
|
337
|
+
segment.roofType === 'dutch'
|
|
338
|
+
? { ...segment, trim: ZERO_TRIM, dutchGabletRake: 0 }
|
|
339
|
+
: { ...segment, trim: ZERO_TRIM }
|
|
340
|
+
const shellGeometry = generateRoofSegmentGeometry(sectionSourceSegment)
|
|
341
|
+
const shell = new Brush(shellGeometry)
|
|
342
|
+
prepareBrushForCSG(shell)
|
|
343
|
+
|
|
344
|
+
const slopeFrame = getSegmentSlopeFrame(segment)
|
|
345
|
+
// Span the full material height — wall bands (base→eave) plus the deck/shingle
|
|
346
|
+
// wedge above — so the cut face fills completely. The earlier red bars that
|
|
347
|
+
// poked below the box were horizontal overshoot (the untrimmed slab dragging
|
|
348
|
+
// wall material past a perpendicular cut), now fixed by the per-end slab
|
|
349
|
+
// extension clamp; clipping the wall band off here only left gaps.
|
|
350
|
+
const vMin = -0.05
|
|
351
|
+
const vMax =
|
|
352
|
+
segment.wallHeight +
|
|
353
|
+
slopeFrame.activeRh +
|
|
354
|
+
segment.deckThickness +
|
|
355
|
+
segment.shingleThickness +
|
|
356
|
+
0.5
|
|
357
|
+
|
|
358
|
+
const fillPositions: number[] = []
|
|
359
|
+
const outlinePositions: number[] = []
|
|
360
|
+
// Section cut only needs material presence, not per-face materials — disable
|
|
361
|
+
// group bookkeeping on the shared evaluator for this pass so mismatched slab /
|
|
362
|
+
// shell material slots can't misalign group indices and crash.
|
|
363
|
+
const prevUseGroups = csgEvaluator.useGroups
|
|
364
|
+
const prevAttributes = csgEvaluator.attributes
|
|
365
|
+
csgEvaluator.useGroups = false
|
|
366
|
+
csgEvaluator.attributes = ['position']
|
|
367
|
+
|
|
368
|
+
// Intersect every active section slab with a source solid (the roof shell or
|
|
369
|
+
// an accessory mesh), appending the resulting cross-section to the shared
|
|
370
|
+
// fill + outline buffers. The slab sits just inside the kept material, so the
|
|
371
|
+
// intersection yields the material face the cut exposes.
|
|
372
|
+
const sliceSourceBySlabs = (source: Brush) => {
|
|
373
|
+
for (const plane of planes) {
|
|
374
|
+
const slab = buildSectionSlabBrush(plane, vMin, vMax)
|
|
375
|
+
if (!slab) continue
|
|
376
|
+
try {
|
|
377
|
+
const result = csgEvaluator.evaluate(source, slab, INTERSECTION) as Brush
|
|
378
|
+
const geo = result.geometry as THREE.BufferGeometry
|
|
379
|
+
const pos = geo.getAttribute('position') as THREE.BufferAttribute | undefined
|
|
380
|
+
if (pos && pos.count > 0) {
|
|
381
|
+
const index = geo.getIndex()
|
|
382
|
+
const count = index ? index.count : pos.count
|
|
383
|
+
for (let i = 0; i < count; i++) {
|
|
384
|
+
const vi = index ? index.getX(i) : i
|
|
385
|
+
fillPositions.push(pos.getX(vi), pos.getY(vi), pos.getZ(vi))
|
|
386
|
+
}
|
|
387
|
+
// Trace the silhouette of the cut face itself. EdgesGeometry emits a
|
|
388
|
+
// boundary/crease line list; the thin wafer's flat cut caps give a
|
|
389
|
+
// crisp outline of the actual material shape.
|
|
390
|
+
const edges = new THREE.EdgesGeometry(geo, 1)
|
|
391
|
+
const ep = edges.getAttribute('position') as THREE.BufferAttribute | undefined
|
|
392
|
+
if (ep) {
|
|
393
|
+
for (let i = 0; i < ep.count; i++) {
|
|
394
|
+
outlinePositions.push(ep.getX(i), ep.getY(i), ep.getZ(i))
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
edges.dispose()
|
|
398
|
+
}
|
|
399
|
+
geo.dispose()
|
|
400
|
+
} catch (e) {
|
|
401
|
+
console.error('Roof section-cut CSG failed:', e)
|
|
402
|
+
} finally {
|
|
403
|
+
slab.geometry.dispose()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Brushes built from accessory meshes; disposed after the slice pass.
|
|
409
|
+
const accessoryBrushes: Brush[] = []
|
|
410
|
+
try {
|
|
411
|
+
sliceSourceBySlabs(shell)
|
|
412
|
+
// Accessories that fall in the trimmed region contribute their own
|
|
413
|
+
// cross-section to the same red fill (chimney, vents, skylight, dormer, …).
|
|
414
|
+
// Each geometry arrives already in segment-local space.
|
|
415
|
+
for (const accGeo of accessoryGeometries) {
|
|
416
|
+
try {
|
|
417
|
+
// Weld coincident verts so the brush is a valid indexed solid. Most
|
|
418
|
+
// accessories are clean THREE primitives, but some (e.g. the ridge
|
|
419
|
+
// vent) are hand-wound non-indexed triangle soup; three-bvh-csg's
|
|
420
|
+
// INTERSECTION classifies inside/outside off a welded, indexed mesh and
|
|
421
|
+
// silently yields nothing for raw soup — the same `mergeVertices` step
|
|
422
|
+
// the viewer's own accessory CSG runs before any boolean op.
|
|
423
|
+
const welded = mergeVertices(accGeo, 1e-4)
|
|
424
|
+
const accBrush = new Brush(welded)
|
|
425
|
+
prepareBrushForCSG(accBrush)
|
|
426
|
+
accessoryBrushes.push(accBrush)
|
|
427
|
+
sliceSourceBySlabs(accBrush)
|
|
428
|
+
} catch (e) {
|
|
429
|
+
console.error('Roof section-cut accessory CSG failed:', e)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} finally {
|
|
433
|
+
csgEvaluator.useGroups = prevUseGroups
|
|
434
|
+
csgEvaluator.attributes = prevAttributes
|
|
435
|
+
shellGeometry.dispose()
|
|
436
|
+
for (const b of accessoryBrushes) b.geometry.dispose()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (fillPositions.length === 0) return null
|
|
440
|
+
|
|
441
|
+
const fill = new THREE.BufferGeometry()
|
|
442
|
+
fill.setAttribute('position', new THREE.Float32BufferAttribute(fillPositions, 3))
|
|
443
|
+
const outline = new THREE.BufferGeometry()
|
|
444
|
+
outline.setAttribute('position', new THREE.Float32BufferAttribute(outlinePositions, 3))
|
|
445
|
+
return { fill, outline }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Zeroed trim — the section fill intersects the FULL (untrimmed) roof shell at
|
|
449
|
+
// the cut line, so we regenerate the shell with no trim and slab-intersect it.
|
|
450
|
+
const ZERO_TRIM: RoofSegmentTrim = {
|
|
451
|
+
left: 0,
|
|
452
|
+
right: 0,
|
|
453
|
+
front: 0,
|
|
454
|
+
back: 0,
|
|
455
|
+
frontLeft: 0,
|
|
456
|
+
frontRight: 0,
|
|
457
|
+
backLeft: 0,
|
|
458
|
+
backRight: 0,
|
|
459
|
+
frontLeftX: 0,
|
|
460
|
+
frontLeftZ: 0,
|
|
461
|
+
frontRightX: 0,
|
|
462
|
+
frontRightZ: 0,
|
|
463
|
+
backLeftX: 0,
|
|
464
|
+
backLeftZ: 0,
|
|
465
|
+
backRightX: 0,
|
|
466
|
+
backRightZ: 0,
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
type TrimVisibleBounds = ReturnType<typeof getRoofSegmentVisibleTopBounds>
|
|
470
|
+
|
|
471
|
+
function getTrimVisibleTopBounds(segment: RoofSegmentNode): TrimVisibleBounds {
|
|
472
|
+
const bounds = getRoofSegmentVisibleTopBounds(segment)
|
|
473
|
+
if (segment.roofType !== 'dutch') return bounds
|
|
474
|
+
|
|
475
|
+
const trim = normalizeRoofSegmentTrim(segment)
|
|
476
|
+
const metrics = getDutchRoofMetrics(segment)
|
|
477
|
+
const requestedRake = Math.max(0, segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake)
|
|
478
|
+
const rakeReach = Math.min(
|
|
479
|
+
requestedRake,
|
|
480
|
+
(metrics.axis === 'x' ? metrics.shoulderInsetAlongWidth : metrics.shoulderInsetAlongDepth) *
|
|
481
|
+
0.98,
|
|
482
|
+
)
|
|
483
|
+
if (!(rakeReach > 0.001)) return bounds
|
|
484
|
+
|
|
485
|
+
const next = { ...bounds }
|
|
486
|
+
if (metrics.axis === 'x') {
|
|
487
|
+
if (!(trim.left > 0)) next.minX -= rakeReach
|
|
488
|
+
if (!(trim.right > 0)) next.maxX += rakeReach
|
|
489
|
+
} else {
|
|
490
|
+
if (!(trim.back > 0)) next.minZ -= rakeReach
|
|
491
|
+
if (!(trim.front > 0)) next.maxZ += rakeReach
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
next.width = Math.max(0.01, next.maxX - next.minX)
|
|
495
|
+
next.depth = Math.max(0.01, next.maxZ - next.minZ)
|
|
496
|
+
return next
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Shape fields that affect the segment's 3D volume. Trim is excluded — the cut
|
|
500
|
+
// lines arrive via `planes`, whose endpoints already encode the trim — so the
|
|
501
|
+
// memo recomputes when either the roof shape or any trim changes, and the shell
|
|
502
|
+
// regeneration only reruns when the actual roof shape changes.
|
|
503
|
+
function segmentShapeKey(segment: RoofSegmentNode): string {
|
|
504
|
+
return JSON.stringify([
|
|
505
|
+
segment.roofType,
|
|
506
|
+
segment.width,
|
|
507
|
+
segment.depth,
|
|
508
|
+
segment.wallHeight,
|
|
509
|
+
segment.pitch,
|
|
510
|
+
segment.wallThickness,
|
|
511
|
+
segment.deckThickness,
|
|
512
|
+
segment.overhang,
|
|
513
|
+
segment.shingleThickness,
|
|
514
|
+
segment.gambrelLowerWidthRatio,
|
|
515
|
+
segment.gambrelLowerHeightRatio,
|
|
516
|
+
segment.mansardSteepWidthRatio,
|
|
517
|
+
segment.mansardSteepHeightRatio,
|
|
518
|
+
segment.dutchHipWidthRatio,
|
|
519
|
+
segment.dutchHipHeightRatio,
|
|
520
|
+
segment.dutchWaistLengthRatio,
|
|
521
|
+
segment.dutchGabletRake,
|
|
522
|
+
segment.dutchTopRakeThickness,
|
|
523
|
+
])
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Collect every roof-accessory mesh hosted on `segment` as a segment-local
|
|
527
|
+
// geometry, so the section-cut pass can intersect each with the cut slabs and
|
|
528
|
+
// show its cross-section in the red fill. Registry-driven — an accessory is any
|
|
529
|
+
// child kind declaring the `roofAccessory` capability — so no kind is named
|
|
530
|
+
// here. The meshes come straight from `sceneRegistry` (already live: each
|
|
531
|
+
// renderer re-clips against the live trim), so the geometry reflects the
|
|
532
|
+
// in-flight drag. Returned geometries are fresh clones the caller owns +
|
|
533
|
+
// disposes.
|
|
534
|
+
const _segWorldInverse = new THREE.Matrix4()
|
|
535
|
+
const _accWorld = new THREE.Matrix4()
|
|
536
|
+
function collectAccessorySectionGeometries(segment: RoofSegmentNode): THREE.BufferGeometry[] {
|
|
537
|
+
const childIds = segment.children
|
|
538
|
+
if (!childIds || childIds.length === 0) return []
|
|
539
|
+
|
|
540
|
+
const segSource = sceneRegistry.nodes.get(segment.id)
|
|
541
|
+
if (!segSource) return []
|
|
542
|
+
segSource.updateWorldMatrix(true, false)
|
|
543
|
+
_segWorldInverse.copy(segSource.matrixWorld).invert()
|
|
544
|
+
|
|
545
|
+
const nodes = useScene.getState().nodes
|
|
546
|
+
const out: THREE.BufferGeometry[] = []
|
|
547
|
+
for (const childId of childIds) {
|
|
548
|
+
const childNode = nodes[childId as AnyNodeId]
|
|
549
|
+
if (!childNode) continue
|
|
550
|
+
if (!nodeRegistry.get(childNode.type)?.capabilities?.roofAccessory) continue
|
|
551
|
+
const obj = sceneRegistry.nodes.get(childId)
|
|
552
|
+
if (!obj) continue
|
|
553
|
+
obj.updateWorldMatrix(true, true)
|
|
554
|
+
obj.traverse((child) => {
|
|
555
|
+
const mesh = child as THREE.Mesh
|
|
556
|
+
if (!mesh.isMesh || !mesh.geometry) return
|
|
557
|
+
const posAttr = mesh.geometry.getAttribute('position') as THREE.BufferAttribute | undefined
|
|
558
|
+
if (!posAttr || posAttr.count === 0) return
|
|
559
|
+
// mesh-world → segment-local: segmentWorld⁻¹ · meshWorld.
|
|
560
|
+
_accWorld.multiplyMatrices(_segWorldInverse, mesh.matrixWorld)
|
|
561
|
+
const geo = mesh.geometry.clone()
|
|
562
|
+
geo.applyMatrix4(_accWorld)
|
|
563
|
+
out.push(geo)
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
return out
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// A change key over the hosted accessories' kinds + world transforms, so the
|
|
570
|
+
// section-cut memo recomputes when an accessory moves, is added/removed, or its
|
|
571
|
+
// host pose shifts (mirrors how `planes` keys the trim cut). Geometry-shape
|
|
572
|
+
// changes are caught by the renderer re-clipping (new mesh world matrix on
|
|
573
|
+
// resize is not guaranteed, but trim drag changes `planes` every tick, which
|
|
574
|
+
// already forces the recompute during the gesture we care about).
|
|
575
|
+
function accessorySectionKey(segment: RoofSegmentNode): string {
|
|
576
|
+
const childIds = segment.children
|
|
577
|
+
if (!childIds || childIds.length === 0) return 'none'
|
|
578
|
+
const nodes = useScene.getState().nodes
|
|
579
|
+
const parts: string[] = []
|
|
580
|
+
for (const childId of childIds) {
|
|
581
|
+
const childNode = nodes[childId as AnyNodeId]
|
|
582
|
+
if (!childNode) continue
|
|
583
|
+
if (!nodeRegistry.get(childNode.type)?.capabilities?.roofAccessory) continue
|
|
584
|
+
const obj = sceneRegistry.nodes.get(childId)
|
|
585
|
+
if (!obj) continue
|
|
586
|
+
parts.push(`${childId}:${obj.matrixWorld.elements.map((n) => n.toFixed(3)).join(',')}`)
|
|
587
|
+
}
|
|
588
|
+
return parts.join('|')
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Renders the cutaway section cut (material fill + cut-edge outline) for the
|
|
592
|
+
// active trim planes, in segment-local space (mounted under the
|
|
593
|
+
// segment-world-matrix group). The fill is the CSG intersection of the
|
|
594
|
+
// untrimmed roof shell (and every hosted accessory) with a thin slab at each
|
|
595
|
+
// cut line, so only real material is shown and the hollow attic stays empty;
|
|
596
|
+
// the outline is the analytic surface edge.
|
|
597
|
+
function SectionCut({ segment, planes }: { segment: RoofSegmentNode; planes: SectionPlaneSpec[] }) {
|
|
598
|
+
const shapeKey = segmentShapeKey(segment)
|
|
599
|
+
const accessoryKey = accessorySectionKey(segment)
|
|
600
|
+
|
|
601
|
+
const geometries = useMemo(() => {
|
|
602
|
+
if (planes.length === 0) return null
|
|
603
|
+
const accessoryGeometries = collectAccessorySectionGeometries(segment)
|
|
604
|
+
const result = buildSectionGeometries(segment, planes, accessoryGeometries)
|
|
605
|
+
for (const g of accessoryGeometries) g.dispose()
|
|
606
|
+
return result
|
|
607
|
+
// Recompute when the roof shape (shapeKey), the cut lines (planes), or the
|
|
608
|
+
// hosted accessories (accessoryKey) change.
|
|
609
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
610
|
+
}, [segment, planes.length, planes])
|
|
611
|
+
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
return () => {
|
|
614
|
+
geometries?.fill.dispose()
|
|
615
|
+
geometries?.outline.dispose()
|
|
616
|
+
}
|
|
617
|
+
}, [geometries])
|
|
618
|
+
|
|
619
|
+
if (!geometries) return null
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<group layers={EDITOR_LAYER}>
|
|
623
|
+
<mesh
|
|
624
|
+
geometry={geometries.fill}
|
|
625
|
+
layers={EDITOR_LAYER}
|
|
626
|
+
material={sectionFillMaterial}
|
|
627
|
+
raycast={() => null}
|
|
628
|
+
renderOrder={SECTION_FILL_RENDER_ORDER}
|
|
629
|
+
/>
|
|
630
|
+
<lineSegments
|
|
631
|
+
frustumCulled={false}
|
|
632
|
+
geometry={geometries.outline}
|
|
633
|
+
layers={EDITOR_LAYER}
|
|
634
|
+
material={sectionOutlineMaterial}
|
|
635
|
+
renderOrder={SECTION_OUTLINE_RENDER_ORDER}
|
|
636
|
+
/>
|
|
637
|
+
</group>
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function HoveredRoofSegmentOutlineProxy() {
|
|
642
|
+
const hoveredId = useViewer((s) => s.hoveredId)
|
|
643
|
+
const segment = useScene((s) => {
|
|
644
|
+
if (!hoveredId) return null
|
|
645
|
+
const node = s.nodes[hoveredId as AnyNodeId]
|
|
646
|
+
return node?.type === 'roof-segment' ? (node as RoofSegmentNode) : null
|
|
647
|
+
})
|
|
648
|
+
const nodes = useScene((s) => s.nodes)
|
|
649
|
+
const ref = useRef<THREE.Mesh>(null)
|
|
650
|
+
|
|
651
|
+
const geometry = useMemo(() => {
|
|
652
|
+
if (!segment) return null
|
|
653
|
+
return generateRoofSegmentGeometry(segment, nodes)
|
|
654
|
+
}, [nodes, segment])
|
|
655
|
+
const source = segment ? (sceneRegistry.nodes.get(segment.id) ?? null) : null
|
|
656
|
+
const roofRoot =
|
|
657
|
+
segment?.parentId && nodes[segment.parentId as AnyNodeId]?.type === 'roof'
|
|
658
|
+
? (sceneRegistry.nodes.get(segment.parentId) ?? null)
|
|
659
|
+
: null
|
|
660
|
+
|
|
661
|
+
useEffect(() => {
|
|
662
|
+
return () => {
|
|
663
|
+
geometry?.dispose()
|
|
664
|
+
}
|
|
665
|
+
}, [geometry])
|
|
666
|
+
|
|
667
|
+
useFrame(() => {
|
|
668
|
+
const mesh = ref.current
|
|
669
|
+
if (!(mesh && source && roofRoot)) return
|
|
670
|
+
source.updateWorldMatrix(true, false)
|
|
671
|
+
roofRoot.updateWorldMatrix(true, false)
|
|
672
|
+
mesh.matrix.copy(roofRoot.matrixWorld).invert().multiply(source.matrixWorld)
|
|
673
|
+
mesh.matrixAutoUpdate = false
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
if (!(source && geometry && roofRoot && segment)) return null
|
|
677
|
+
|
|
678
|
+
return createPortal(
|
|
679
|
+
<mesh
|
|
680
|
+
frustumCulled={false}
|
|
681
|
+
geometry={geometry}
|
|
682
|
+
layers={EDITOR_LAYER}
|
|
683
|
+
material={hoverOutlineProxyMaterial}
|
|
684
|
+
matrixAutoUpdate={false}
|
|
685
|
+
name={getHoveredRoofSegmentOutlineProxyName(segment.id)}
|
|
686
|
+
raycast={() => null}
|
|
687
|
+
ref={ref}
|
|
688
|
+
/>,
|
|
689
|
+
roofRoot,
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const _dragNdc = new THREE.Vector2()
|
|
694
|
+
const _dragRaycaster = new THREE.Raycaster()
|
|
695
|
+
const _dragPlaneHit = new THREE.Vector3()
|
|
696
|
+
const _dragLocalPoint = new THREE.Vector3()
|
|
697
|
+
const _dragInverseMatrix = new THREE.Matrix4()
|
|
698
|
+
const _trimHitInverseMatrix = new THREE.Matrix4()
|
|
699
|
+
const _trimHitRay = new THREE.Ray()
|
|
700
|
+
const _trimHitBox = new THREE.Box3()
|
|
701
|
+
const _trimHitPoint = new THREE.Vector3()
|
|
702
|
+
|
|
703
|
+
function makeExpandedTrimRaycast(
|
|
704
|
+
visualScale: readonly [number, number, number],
|
|
705
|
+
hitScale: readonly [number, number, number],
|
|
706
|
+
) {
|
|
707
|
+
const halfX = Math.max(0.5, hitScale[0] / Math.max(visualScale[0], 1e-6) / 2)
|
|
708
|
+
const halfY = Math.max(0.5, hitScale[1] / Math.max(visualScale[1], 1e-6) / 2)
|
|
709
|
+
const halfZ = Math.max(0.5, hitScale[2] / Math.max(visualScale[2], 1e-6) / 2)
|
|
710
|
+
return function expandedTrimRaycast(
|
|
711
|
+
this: THREE.Mesh,
|
|
712
|
+
raycaster: THREE.Raycaster,
|
|
713
|
+
intersects: THREE.Intersection[],
|
|
714
|
+
) {
|
|
715
|
+
_trimHitInverseMatrix.copy(this.matrixWorld).invert()
|
|
716
|
+
_trimHitRay.copy(raycaster.ray).applyMatrix4(_trimHitInverseMatrix)
|
|
717
|
+
_trimHitBox.min.set(-halfX, -halfY, -halfZ)
|
|
718
|
+
_trimHitBox.max.set(halfX, halfY, halfZ)
|
|
719
|
+
const localHit = _trimHitRay.intersectBox(_trimHitBox, _trimHitPoint)
|
|
720
|
+
if (!localHit) return
|
|
721
|
+
const point = localHit.clone().applyMatrix4(this.matrixWorld)
|
|
722
|
+
const distance = raycaster.ray.origin.distanceTo(point)
|
|
723
|
+
if (distance < raycaster.near || distance > raycaster.far) return
|
|
724
|
+
intersects.push({ distance, point, object: this })
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function trimEquals(a: RoofSegmentTrim, b: RoofSegmentTrim): boolean {
|
|
729
|
+
return (
|
|
730
|
+
a.left === b.left &&
|
|
731
|
+
a.right === b.right &&
|
|
732
|
+
a.front === b.front &&
|
|
733
|
+
a.back === b.back &&
|
|
734
|
+
a.frontLeft === b.frontLeft &&
|
|
735
|
+
a.frontRight === b.frontRight &&
|
|
736
|
+
a.backLeft === b.backLeft &&
|
|
737
|
+
a.backRight === b.backRight &&
|
|
738
|
+
a.frontLeftX === b.frontLeftX &&
|
|
739
|
+
a.frontLeftZ === b.frontLeftZ &&
|
|
740
|
+
a.frontRightX === b.frontRightX &&
|
|
741
|
+
a.frontRightZ === b.frontRightZ &&
|
|
742
|
+
a.backLeftX === b.backLeftX &&
|
|
743
|
+
a.backLeftZ === b.backLeftZ &&
|
|
744
|
+
a.backRightX === b.backRightX &&
|
|
745
|
+
a.backRightZ === b.backRightZ
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function clamp(value: number, min: number, max: number): number {
|
|
750
|
+
return Math.min(max, Math.max(min, value))
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function isDiagonalTrimSide(side: RoofTrimSide): side is DiagonalTrimSide {
|
|
754
|
+
return (
|
|
755
|
+
side === 'frontLeft' || side === 'frontRight' || side === 'backLeft' || side === 'backRight'
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function getDiagonalAxisKeys(side: DiagonalTrimSide): [DiagonalTrimAxisKey, DiagonalTrimAxisKey] {
|
|
760
|
+
switch (side) {
|
|
761
|
+
case 'frontLeft':
|
|
762
|
+
return ['frontLeftX', 'frontLeftZ']
|
|
763
|
+
case 'frontRight':
|
|
764
|
+
return ['frontRightX', 'frontRightZ']
|
|
765
|
+
case 'backLeft':
|
|
766
|
+
return ['backLeftX', 'backLeftZ']
|
|
767
|
+
case 'backRight':
|
|
768
|
+
return ['backRightX', 'backRightZ']
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function getDiagonalResetCorner(side: RoofTrimSide): DiagonalTrimSide | null {
|
|
773
|
+
if (isDiagonalTrimSide(side)) return side
|
|
774
|
+
if (side.endsWith('X') || side.endsWith('Z')) {
|
|
775
|
+
return getDiagonalAxisCorner(side as DiagonalTrimAxisSide)
|
|
776
|
+
}
|
|
777
|
+
return null
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function getDiagonalAxisCorner(side: DiagonalTrimAxisSide): DiagonalTrimSide {
|
|
781
|
+
if (side === 'frontLeftX' || side === 'frontLeftZ') return 'frontLeft'
|
|
782
|
+
if (side === 'frontRightX' || side === 'frontRightZ') return 'frontRight'
|
|
783
|
+
if (side === 'backLeftX' || side === 'backLeftZ') return 'backLeft'
|
|
784
|
+
return 'backRight'
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function getOppositeDiagonalAxis(side: DiagonalTrimAxisKey): DiagonalTrimAxisKey {
|
|
788
|
+
switch (side) {
|
|
789
|
+
case 'frontLeftX':
|
|
790
|
+
return 'frontRightX'
|
|
791
|
+
case 'frontRightX':
|
|
792
|
+
return 'frontLeftX'
|
|
793
|
+
case 'backLeftX':
|
|
794
|
+
return 'backRightX'
|
|
795
|
+
case 'backRightX':
|
|
796
|
+
return 'backLeftX'
|
|
797
|
+
case 'frontLeftZ':
|
|
798
|
+
return 'backLeftZ'
|
|
799
|
+
case 'backLeftZ':
|
|
800
|
+
return 'frontLeftZ'
|
|
801
|
+
case 'frontRightZ':
|
|
802
|
+
return 'backRightZ'
|
|
803
|
+
case 'backRightZ':
|
|
804
|
+
return 'frontRightZ'
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function getMaxDiagonalAxisTrim(
|
|
809
|
+
segment: RoofSegmentNode,
|
|
810
|
+
trim: RoofSegmentTrim,
|
|
811
|
+
axis: DiagonalTrimAxisKey,
|
|
812
|
+
): number {
|
|
813
|
+
const keptWidth = Math.max(0, segment.width - trim.left - trim.right)
|
|
814
|
+
const keptDepth = Math.max(0, segment.depth - trim.front - trim.back)
|
|
815
|
+
const opposite = trim[getOppositeDiagonalAxis(axis)]
|
|
816
|
+
const span = axis.endsWith('X') ? keptWidth : keptDepth
|
|
817
|
+
return Math.max(0, span - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function getStarterDiagonalTrim(segment: RoofSegmentNode, trim: RoofSegmentTrim): number {
|
|
821
|
+
const keptWidth = Math.max(0, segment.width - trim.left - trim.right)
|
|
822
|
+
const keptDepth = Math.max(0, segment.depth - trim.front - trim.back)
|
|
823
|
+
const maxDiagonalTrim = Math.max(0, Math.min(keptWidth, keptDepth) - MIN_ROOF_SEGMENT_TRIM_SPAN)
|
|
824
|
+
return Math.min(maxDiagonalTrim, Math.max(0.75, maxDiagonalTrim * 0.2))
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function patchTrimSide(
|
|
828
|
+
segment: RoofSegmentNode,
|
|
829
|
+
baseTrim: RoofSegmentTrim,
|
|
830
|
+
side: RoofTrimSide,
|
|
831
|
+
rawValue: number,
|
|
832
|
+
): RoofSegmentTrim {
|
|
833
|
+
const next = { ...baseTrim }
|
|
834
|
+
if (side === 'left' || side === 'right') {
|
|
835
|
+
const opposite = side === 'left' ? baseTrim.right : baseTrim.left
|
|
836
|
+
const max = Math.max(0, segment.width - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite)
|
|
837
|
+
next[side] = clamp(rawValue, 0, max)
|
|
838
|
+
} else {
|
|
839
|
+
if (isDiagonalTrimSide(side)) {
|
|
840
|
+
const [xAxis, zAxis] = getDiagonalAxisKeys(side)
|
|
841
|
+
next[xAxis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, xAxis))
|
|
842
|
+
next[zAxis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, zAxis))
|
|
843
|
+
next[side] = Math.min(next[xAxis], next[zAxis])
|
|
844
|
+
return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next })
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (side.endsWith('X') || side.endsWith('Z')) {
|
|
848
|
+
const axis = side as DiagonalTrimAxisKey
|
|
849
|
+
const corner = getDiagonalAxisCorner(axis)
|
|
850
|
+
const [xAxis, zAxis] = getDiagonalAxisKeys(corner)
|
|
851
|
+
const otherAxis = axis === xAxis ? zAxis : xAxis
|
|
852
|
+
const starter = getStarterDiagonalTrim(segment, baseTrim)
|
|
853
|
+
next[axis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, axis))
|
|
854
|
+
if (next[otherAxis] <= 0 && next[corner] <= 0) {
|
|
855
|
+
next[otherAxis] = Math.min(starter, getMaxDiagonalAxisTrim(segment, baseTrim, otherAxis))
|
|
856
|
+
}
|
|
857
|
+
next[corner] = Math.min(next[xAxis], next[zAxis])
|
|
858
|
+
return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next })
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const opposite = side === 'front' ? baseTrim.back : baseTrim.front
|
|
862
|
+
const max = Math.max(0, segment.depth - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite)
|
|
863
|
+
next[side] = clamp(rawValue, 0, max)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next })
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function patchTrimSideByDelta(
|
|
870
|
+
segment: RoofSegmentNode,
|
|
871
|
+
baseTrim: RoofSegmentTrim,
|
|
872
|
+
side: RoofTrimSide,
|
|
873
|
+
delta: number,
|
|
874
|
+
): RoofSegmentTrim {
|
|
875
|
+
if (isDiagonalTrimSide(side)) {
|
|
876
|
+
const [xAxis, zAxis] = getDiagonalAxisKeys(side)
|
|
877
|
+
const next = { ...baseTrim }
|
|
878
|
+
next[xAxis] = clamp(
|
|
879
|
+
baseTrim[xAxis] + delta,
|
|
880
|
+
0,
|
|
881
|
+
getMaxDiagonalAxisTrim(segment, baseTrim, xAxis),
|
|
882
|
+
)
|
|
883
|
+
next[zAxis] = clamp(
|
|
884
|
+
baseTrim[zAxis] + delta,
|
|
885
|
+
0,
|
|
886
|
+
getMaxDiagonalAxisTrim(segment, baseTrim, zAxis),
|
|
887
|
+
)
|
|
888
|
+
next[side] = Math.min(next[xAxis], next[zAxis])
|
|
889
|
+
return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next })
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const baseValue = baseTrim[side]
|
|
893
|
+
return patchTrimSide(segment, baseTrim, side, baseValue + delta)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function getTrimValueFromLocalPoint(
|
|
897
|
+
segment: RoofSegmentNode,
|
|
898
|
+
baseTrim: RoofSegmentTrim,
|
|
899
|
+
side: RoofTrimSide,
|
|
900
|
+
localPoint: THREE.Vector3,
|
|
901
|
+
): number {
|
|
902
|
+
const leftX = -segment.width / 2 + baseTrim.left
|
|
903
|
+
const rightX = segment.width / 2 - baseTrim.right
|
|
904
|
+
const frontZ = segment.depth / 2 - baseTrim.front
|
|
905
|
+
const backZ = -segment.depth / 2 + baseTrim.back
|
|
906
|
+
|
|
907
|
+
switch (side) {
|
|
908
|
+
case 'left':
|
|
909
|
+
return localPoint.x + segment.width / 2
|
|
910
|
+
case 'right':
|
|
911
|
+
return segment.width / 2 - localPoint.x
|
|
912
|
+
case 'front':
|
|
913
|
+
return segment.depth / 2 - localPoint.z
|
|
914
|
+
case 'back':
|
|
915
|
+
return localPoint.z + segment.depth / 2
|
|
916
|
+
case 'frontLeft':
|
|
917
|
+
return localPoint.x - leftX + (frontZ - localPoint.z)
|
|
918
|
+
case 'frontRight':
|
|
919
|
+
return rightX - localPoint.x + (frontZ - localPoint.z)
|
|
920
|
+
case 'backLeft':
|
|
921
|
+
return localPoint.x - leftX + (localPoint.z - backZ)
|
|
922
|
+
case 'backRight':
|
|
923
|
+
return rightX - localPoint.x + (localPoint.z - backZ)
|
|
924
|
+
case 'frontLeftX':
|
|
925
|
+
return localPoint.x - leftX
|
|
926
|
+
case 'frontLeftZ':
|
|
927
|
+
return frontZ - localPoint.z
|
|
928
|
+
case 'frontRightX':
|
|
929
|
+
return rightX - localPoint.x
|
|
930
|
+
case 'frontRightZ':
|
|
931
|
+
return frontZ - localPoint.z
|
|
932
|
+
case 'backLeftX':
|
|
933
|
+
return localPoint.x - leftX
|
|
934
|
+
case 'backLeftZ':
|
|
935
|
+
return localPoint.z - backZ
|
|
936
|
+
case 'backRightX':
|
|
937
|
+
return rightX - localPoint.x
|
|
938
|
+
case 'backRightZ':
|
|
939
|
+
return localPoint.z - backZ
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function getTrimLabel(side: RoofTrimSide): string {
|
|
944
|
+
switch (side) {
|
|
945
|
+
case 'left':
|
|
946
|
+
return 'trim left'
|
|
947
|
+
case 'right':
|
|
948
|
+
return 'trim right'
|
|
949
|
+
case 'front':
|
|
950
|
+
return 'trim front'
|
|
951
|
+
case 'back':
|
|
952
|
+
return 'trim back'
|
|
953
|
+
case 'frontLeft':
|
|
954
|
+
return 'trim front left diagonal'
|
|
955
|
+
case 'frontRight':
|
|
956
|
+
return 'trim front right diagonal'
|
|
957
|
+
case 'backLeft':
|
|
958
|
+
return 'trim back left diagonal'
|
|
959
|
+
case 'backRight':
|
|
960
|
+
return 'trim back right diagonal'
|
|
961
|
+
case 'frontLeftX':
|
|
962
|
+
return 'trim front left diagonal width'
|
|
963
|
+
case 'frontLeftZ':
|
|
964
|
+
return 'trim front left diagonal depth'
|
|
965
|
+
case 'frontRightX':
|
|
966
|
+
return 'trim front right diagonal width'
|
|
967
|
+
case 'frontRightZ':
|
|
968
|
+
return 'trim front right diagonal depth'
|
|
969
|
+
case 'backLeftX':
|
|
970
|
+
return 'trim back left diagonal width'
|
|
971
|
+
case 'backLeftZ':
|
|
972
|
+
return 'trim back left diagonal depth'
|
|
973
|
+
case 'backRightX':
|
|
974
|
+
return 'trim back right diagonal width'
|
|
975
|
+
case 'backRightZ':
|
|
976
|
+
return 'trim back right diagonal depth'
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function getTrimCursor(side: RoofTrimSide): string {
|
|
981
|
+
switch (side) {
|
|
982
|
+
case 'left':
|
|
983
|
+
case 'right':
|
|
984
|
+
return 'ew-resize'
|
|
985
|
+
case 'front':
|
|
986
|
+
case 'back':
|
|
987
|
+
return 'ns-resize'
|
|
988
|
+
case 'frontLeft':
|
|
989
|
+
case 'backRight':
|
|
990
|
+
return 'nwse-resize'
|
|
991
|
+
case 'frontRight':
|
|
992
|
+
case 'backLeft':
|
|
993
|
+
return 'nesw-resize'
|
|
994
|
+
case 'frontLeftX':
|
|
995
|
+
case 'frontRightX':
|
|
996
|
+
case 'backLeftX':
|
|
997
|
+
case 'backRightX':
|
|
998
|
+
return 'ew-resize'
|
|
999
|
+
case 'frontLeftZ':
|
|
1000
|
+
case 'frontRightZ':
|
|
1001
|
+
case 'backLeftZ':
|
|
1002
|
+
case 'backRightZ':
|
|
1003
|
+
return 'ns-resize'
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function shouldShowTrimPlanes(metadata: unknown): boolean {
|
|
1008
|
+
return (
|
|
1009
|
+
typeof metadata === 'object' &&
|
|
1010
|
+
metadata !== null &&
|
|
1011
|
+
!Array.isArray(metadata) &&
|
|
1012
|
+
(metadata as Record<string, unknown>).showTrimPlanes === true
|
|
1013
|
+
)
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function commitSegmentTrim(segment: RoofSegmentNode, trim: RoofSegmentTrim) {
|
|
1017
|
+
const scene = useScene.getState()
|
|
1018
|
+
scene.applyNodeChanges({
|
|
1019
|
+
update: [{ id: segment.id as AnyNodeId, data: { trim } as Partial<AnyNode> }],
|
|
1020
|
+
})
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function RoofTrimHandles() {
|
|
1024
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
1025
|
+
const selectedId = selectedIds.length === 1 ? (selectedIds[0] as AnyNodeId) : null
|
|
1026
|
+
const segment = useScene((s) => {
|
|
1027
|
+
if (!selectedId) return null
|
|
1028
|
+
const node = s.nodes[selectedId]
|
|
1029
|
+
return node?.type === 'roof-segment' ? (node as RoofSegmentNode) : null
|
|
1030
|
+
})
|
|
1031
|
+
const readOnly = useScene((s) => s.readOnly)
|
|
1032
|
+
const liveOverrideKey = useLiveNodeOverrides((s) =>
|
|
1033
|
+
segment ? JSON.stringify(s.overrides.get(segment.id) ?? null) : null,
|
|
1034
|
+
)
|
|
1035
|
+
const [hoveredSide, setHoveredSide] = useState<RoofTrimSide | null>(null)
|
|
1036
|
+
const [draggingSide, setDraggingSide] = useState<RoofTrimSide | null>(null)
|
|
1037
|
+
const groupRef = useRef<THREE.Group>(null)
|
|
1038
|
+
const dragCleanupRef = useRef<(() => void) | null>(null)
|
|
1039
|
+
const { camera, gl } = useThree()
|
|
1040
|
+
const zoom = camera instanceof THREE.OrthographicCamera ? 1 / camera.zoom : 1
|
|
1041
|
+
const handleBaseScale = zoom * TRIM_HANDLE_BASE_SCALE
|
|
1042
|
+
|
|
1043
|
+
useEffect(() => () => dragCleanupRef.current?.(), [])
|
|
1044
|
+
|
|
1045
|
+
const liveSegment = useMemo(() => {
|
|
1046
|
+
if (!segment) return null
|
|
1047
|
+
void liveOverrideKey
|
|
1048
|
+
return getEffectiveNode(segment)
|
|
1049
|
+
}, [segment, liveOverrideKey])
|
|
1050
|
+
|
|
1051
|
+
useFrame(() => {
|
|
1052
|
+
if (!liveSegment || !groupRef.current) return
|
|
1053
|
+
const source = sceneRegistry.nodes.get(liveSegment.id)
|
|
1054
|
+
if (!source) return
|
|
1055
|
+
source.updateWorldMatrix(true, false)
|
|
1056
|
+
groupRef.current.matrix.copy(source.matrixWorld)
|
|
1057
|
+
groupRef.current.matrixAutoUpdate = false
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
const showTrimPlanes = shouldShowTrimPlanes(liveSegment?.metadata)
|
|
1061
|
+
|
|
1062
|
+
if (readOnly || !segment || !liveSegment || !showTrimPlanes) return null
|
|
1063
|
+
|
|
1064
|
+
const trim = normalizeRoofSegmentTrim(liveSegment)
|
|
1065
|
+
const activeRh = getActiveRoofHeight(liveSegment)
|
|
1066
|
+
const handleY = Math.max(0.45, liveSegment.wallHeight + activeRh + 0.45)
|
|
1067
|
+
const keptWidth = Math.max(0.01, liveSegment.width - trim.left - trim.right)
|
|
1068
|
+
const keptDepth = Math.max(0.01, liveSegment.depth - trim.front - trim.back)
|
|
1069
|
+
const leftX = -liveSegment.width / 2 + trim.left
|
|
1070
|
+
const rightX = liveSegment.width / 2 - trim.right
|
|
1071
|
+
const frontZ = liveSegment.depth / 2 - trim.front
|
|
1072
|
+
const backZ = -liveSegment.depth / 2 + trim.back
|
|
1073
|
+
const visibleBounds = getTrimVisibleTopBounds({
|
|
1074
|
+
...liveSegment,
|
|
1075
|
+
trim: {
|
|
1076
|
+
...trim,
|
|
1077
|
+
frontLeft: 0,
|
|
1078
|
+
frontRight: 0,
|
|
1079
|
+
backLeft: 0,
|
|
1080
|
+
backRight: 0,
|
|
1081
|
+
frontLeftX: 0,
|
|
1082
|
+
frontLeftZ: 0,
|
|
1083
|
+
frontRightX: 0,
|
|
1084
|
+
frontRightZ: 0,
|
|
1085
|
+
backLeftX: 0,
|
|
1086
|
+
backLeftZ: 0,
|
|
1087
|
+
backRightX: 0,
|
|
1088
|
+
backRightZ: 0,
|
|
1089
|
+
},
|
|
1090
|
+
})
|
|
1091
|
+
const visibleCenterX = (visibleBounds.minX + visibleBounds.maxX) / 2
|
|
1092
|
+
const visibleCenterZ = (visibleBounds.minZ + visibleBounds.maxZ) / 2
|
|
1093
|
+
const visibleWidth = Math.max(0.01, visibleBounds.maxX - visibleBounds.minX)
|
|
1094
|
+
const visibleDepth = Math.max(0.01, visibleBounds.maxZ - visibleBounds.minZ)
|
|
1095
|
+
const visualLeftX = trim.left > 0 ? leftX : visibleBounds.minX
|
|
1096
|
+
const visualRightX = trim.right > 0 ? rightX : visibleBounds.maxX
|
|
1097
|
+
const visualFrontZ = trim.front > 0 ? frontZ : visibleBounds.maxZ
|
|
1098
|
+
const visualBackZ = trim.back > 0 ? backZ : visibleBounds.minZ
|
|
1099
|
+
const maxDiagonalTrim = Math.max(0, Math.min(keptWidth, keptDepth) - MIN_ROOF_SEGMENT_TRIM_SPAN)
|
|
1100
|
+
|
|
1101
|
+
const pointOnTrimLineAtX = (
|
|
1102
|
+
start: readonly [number, number],
|
|
1103
|
+
end: readonly [number, number],
|
|
1104
|
+
x: number,
|
|
1105
|
+
): [number, number] => {
|
|
1106
|
+
const dx = end[0] - start[0]
|
|
1107
|
+
if (Math.abs(dx) < 1e-6) return [x, start[1]]
|
|
1108
|
+
const t = (x - start[0]) / dx
|
|
1109
|
+
return [x, start[1] + (end[1] - start[1]) * t]
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const pointOnTrimLineAtZ = (
|
|
1113
|
+
start: readonly [number, number],
|
|
1114
|
+
end: readonly [number, number],
|
|
1115
|
+
z: number,
|
|
1116
|
+
): [number, number] => {
|
|
1117
|
+
const dz = end[1] - start[1]
|
|
1118
|
+
if (Math.abs(dz) < 1e-6) return [start[0], z]
|
|
1119
|
+
const t = (z - start[1]) / dz
|
|
1120
|
+
return [start[0] + (end[0] - start[0]) * t, z]
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const getDiagonalRailLine = (
|
|
1124
|
+
side: DiagonalTrimSide,
|
|
1125
|
+
start: readonly [number, number],
|
|
1126
|
+
end: readonly [number, number],
|
|
1127
|
+
): [[number, number], [number, number]] => {
|
|
1128
|
+
switch (side) {
|
|
1129
|
+
case 'frontLeft':
|
|
1130
|
+
return [
|
|
1131
|
+
pointOnTrimLineAtZ(start, end, visualFrontZ),
|
|
1132
|
+
pointOnTrimLineAtX(start, end, visualLeftX),
|
|
1133
|
+
]
|
|
1134
|
+
case 'frontRight':
|
|
1135
|
+
return [
|
|
1136
|
+
pointOnTrimLineAtX(start, end, visualRightX),
|
|
1137
|
+
pointOnTrimLineAtZ(start, end, visualFrontZ),
|
|
1138
|
+
]
|
|
1139
|
+
case 'backLeft':
|
|
1140
|
+
return [
|
|
1141
|
+
pointOnTrimLineAtX(start, end, visualLeftX),
|
|
1142
|
+
pointOnTrimLineAtZ(start, end, visualBackZ),
|
|
1143
|
+
]
|
|
1144
|
+
case 'backRight':
|
|
1145
|
+
return [
|
|
1146
|
+
pointOnTrimLineAtZ(start, end, visualBackZ),
|
|
1147
|
+
pointOnTrimLineAtX(start, end, visualRightX),
|
|
1148
|
+
]
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Cross-section planes the active trim cuts expose. Slice the live roof
|
|
1153
|
+
// mesh just inside the kept material (the cut sits coplanar with the mesh's
|
|
1154
|
+
// own face otherwise) so the cutaway shows real construction layers.
|
|
1155
|
+
const sectionPlanes: SectionPlaneSpec[] = []
|
|
1156
|
+
// Inside reference: the kept-region center, on the kept side of every cut so
|
|
1157
|
+
// the inset always shifts the slice into solid material.
|
|
1158
|
+
const insideRef: readonly [number, number] = [0, 0]
|
|
1159
|
+
if (trim.left > 0) {
|
|
1160
|
+
sectionPlanes.push(
|
|
1161
|
+
makeSectionPlane(leftX, visualBackZ, leftX, visualFrontZ, SECTION_PLANE_INSET, insideRef),
|
|
1162
|
+
)
|
|
1163
|
+
}
|
|
1164
|
+
if (trim.right > 0) {
|
|
1165
|
+
sectionPlanes.push(
|
|
1166
|
+
makeSectionPlane(rightX, visualBackZ, rightX, visualFrontZ, SECTION_PLANE_INSET, insideRef),
|
|
1167
|
+
)
|
|
1168
|
+
}
|
|
1169
|
+
if (trim.front > 0) {
|
|
1170
|
+
sectionPlanes.push(
|
|
1171
|
+
makeSectionPlane(visualLeftX, frontZ, visualRightX, frontZ, SECTION_PLANE_INSET, insideRef),
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
if (trim.back > 0) {
|
|
1175
|
+
sectionPlanes.push(
|
|
1176
|
+
makeSectionPlane(visualLeftX, backZ, visualRightX, backZ, SECTION_PLANE_INSET, insideRef),
|
|
1177
|
+
)
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Diagonal/corner cuts run at an angle, so they need a generic vertical
|
|
1181
|
+
// plane through the corner cut line. The endpoints match the rail line
|
|
1182
|
+
// geometry in renderDiagonalTrimPlane (start/end before the visual-bounds
|
|
1183
|
+
// extension; only direction matters for slicing).
|
|
1184
|
+
const diagonalCutLine = (side: DiagonalTrimSide): [[number, number], [number, number]] | null => {
|
|
1185
|
+
const [xKey, zKey] = getDiagonalAxisKeys(side)
|
|
1186
|
+
const dx = trim[xKey]
|
|
1187
|
+
const dz = trim[zKey]
|
|
1188
|
+
if (!(dx > 0 && dz > 0)) return null
|
|
1189
|
+
switch (side) {
|
|
1190
|
+
case 'frontLeft':
|
|
1191
|
+
return [
|
|
1192
|
+
[leftX + dx, frontZ],
|
|
1193
|
+
[leftX, frontZ - dz],
|
|
1194
|
+
]
|
|
1195
|
+
case 'frontRight':
|
|
1196
|
+
return [
|
|
1197
|
+
[rightX, frontZ - dz],
|
|
1198
|
+
[rightX - dx, frontZ],
|
|
1199
|
+
]
|
|
1200
|
+
case 'backLeft':
|
|
1201
|
+
return [
|
|
1202
|
+
[leftX, backZ + dz],
|
|
1203
|
+
[leftX + dx, backZ],
|
|
1204
|
+
]
|
|
1205
|
+
case 'backRight':
|
|
1206
|
+
return [
|
|
1207
|
+
[rightX - dx, backZ],
|
|
1208
|
+
[rightX, backZ + dz],
|
|
1209
|
+
]
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
for (const side of ['frontLeft', 'frontRight', 'backLeft', 'backRight'] as const) {
|
|
1213
|
+
const line = diagonalCutLine(side)
|
|
1214
|
+
if (!line) continue
|
|
1215
|
+
const [s, e] = line
|
|
1216
|
+
const [railStart, railEnd] = getDiagonalRailLine(side, s, e)
|
|
1217
|
+
sectionPlanes.push(
|
|
1218
|
+
makeSectionPlane(
|
|
1219
|
+
railStart[0],
|
|
1220
|
+
railStart[1],
|
|
1221
|
+
railEnd[0],
|
|
1222
|
+
railEnd[1],
|
|
1223
|
+
SECTION_PLANE_INSET,
|
|
1224
|
+
insideRef,
|
|
1225
|
+
),
|
|
1226
|
+
)
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const resetDiagonalTrim = (side: RoofTrimSide, event: ThreeEvent<MouseEvent>) => {
|
|
1230
|
+
event.stopPropagation()
|
|
1231
|
+
const corner = getDiagonalResetCorner(side)
|
|
1232
|
+
if (!corner) return
|
|
1233
|
+
|
|
1234
|
+
const baseSegment = getEffectiveNode(segment)
|
|
1235
|
+
const baseTrim = normalizeRoofSegmentTrim(baseSegment)
|
|
1236
|
+
const next = { ...baseTrim }
|
|
1237
|
+
const [xAxis, zAxis] = getDiagonalAxisKeys(corner)
|
|
1238
|
+
next[corner] = 0
|
|
1239
|
+
next[xAxis] = 0
|
|
1240
|
+
next[zAxis] = 0
|
|
1241
|
+
const normalized = normalizeRoofSegmentTrim({
|
|
1242
|
+
width: baseSegment.width,
|
|
1243
|
+
depth: baseSegment.depth,
|
|
1244
|
+
trim: next,
|
|
1245
|
+
})
|
|
1246
|
+
useLiveNodeOverrides.getState().clear(segment.id as AnyNodeId)
|
|
1247
|
+
if (!trimEquals(normalized, baseTrim)) {
|
|
1248
|
+
commitSegmentTrim(baseSegment, normalized)
|
|
1249
|
+
}
|
|
1250
|
+
useScene.getState().markDirty(segment.id as AnyNodeId)
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const startDrag = (side: RoofTrimSide, event: ThreeEvent<PointerEvent>) => {
|
|
1254
|
+
if (event.button !== 0) return
|
|
1255
|
+
event.stopPropagation()
|
|
1256
|
+
const source = sceneRegistry.nodes.get(segment.id)
|
|
1257
|
+
if (!source) return
|
|
1258
|
+
|
|
1259
|
+
source.updateWorldMatrix(true, false)
|
|
1260
|
+
const startMatrix = source.matrixWorld.clone()
|
|
1261
|
+
_dragInverseMatrix.copy(startMatrix).invert()
|
|
1262
|
+
const dragPlanePoint = new THREE.Vector3(0, handleY, 0).applyMatrix4(startMatrix)
|
|
1263
|
+
const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -dragPlanePoint.y)
|
|
1264
|
+
const baseSegment = getEffectiveNode(segment)
|
|
1265
|
+
const baseTrim = normalizeRoofSegmentTrim(baseSegment)
|
|
1266
|
+
const segmentId = segment.id as AnyNodeId
|
|
1267
|
+
let pendingTrim = baseTrim
|
|
1268
|
+
let lastDirtyMarkAt = 0
|
|
1269
|
+
let pendingDirtyTimeout: number | null = null
|
|
1270
|
+
|
|
1271
|
+
const clearPendingDirtyTimeout = () => {
|
|
1272
|
+
if (pendingDirtyTimeout === null) return
|
|
1273
|
+
window.clearTimeout(pendingDirtyTimeout)
|
|
1274
|
+
pendingDirtyTimeout = null
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const flushDirtyMark = () => {
|
|
1278
|
+
clearPendingDirtyTimeout()
|
|
1279
|
+
lastDirtyMarkAt = performance.now()
|
|
1280
|
+
useScene.getState().markDirty(segmentId)
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const scheduleDirtyMark = () => {
|
|
1284
|
+
const now = performance.now()
|
|
1285
|
+
if (now - lastDirtyMarkAt >= TRIM_LIVE_REBUILD_INTERVAL_MS) {
|
|
1286
|
+
flushDirtyMark()
|
|
1287
|
+
return
|
|
1288
|
+
}
|
|
1289
|
+
if (pendingDirtyTimeout !== null) return
|
|
1290
|
+
pendingDirtyTimeout = window.setTimeout(
|
|
1291
|
+
() => {
|
|
1292
|
+
flushDirtyMark()
|
|
1293
|
+
},
|
|
1294
|
+
Math.max(0, TRIM_LIVE_REBUILD_INTERVAL_MS - (now - lastDirtyMarkAt)),
|
|
1295
|
+
)
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const getPointerTrimValue = (clientX: number, clientY: number): number | null => {
|
|
1299
|
+
const rect = gl.domElement.getBoundingClientRect()
|
|
1300
|
+
_dragNdc.set(
|
|
1301
|
+
((clientX - rect.left) / rect.width) * 2 - 1,
|
|
1302
|
+
-(((clientY - rect.top) / rect.height) * 2 - 1),
|
|
1303
|
+
)
|
|
1304
|
+
_dragRaycaster.setFromCamera(_dragNdc, camera)
|
|
1305
|
+
if (!_dragRaycaster.ray.intersectPlane(dragPlane, _dragPlaneHit)) return null
|
|
1306
|
+
_dragLocalPoint.copy(_dragPlaneHit).applyMatrix4(_dragInverseMatrix)
|
|
1307
|
+
return getTrimValueFromLocalPoint(baseSegment, baseTrim, side, _dragLocalPoint)
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const initialPointerValue = getPointerTrimValue(event.clientX, event.clientY)
|
|
1311
|
+
if (initialPointerValue === null) return
|
|
1312
|
+
|
|
1313
|
+
document.body.style.cursor = getTrimCursor(side)
|
|
1314
|
+
setDraggingSide(side)
|
|
1315
|
+
useInteractionScope
|
|
1316
|
+
.getState()
|
|
1317
|
+
.begin({ kind: 'handle-drag', nodeId: segmentId, handle: getTrimLabel(side) })
|
|
1318
|
+
useViewer.getState().setInputDragging(true)
|
|
1319
|
+
useScene.temporal.getState().pause()
|
|
1320
|
+
|
|
1321
|
+
const updateFromPointer = (clientX: number, clientY: number) => {
|
|
1322
|
+
const pointerValue = getPointerTrimValue(clientX, clientY)
|
|
1323
|
+
if (pointerValue === null) return
|
|
1324
|
+
pendingTrim = patchTrimSideByDelta(
|
|
1325
|
+
baseSegment,
|
|
1326
|
+
baseTrim,
|
|
1327
|
+
side,
|
|
1328
|
+
pointerValue - initialPointerValue,
|
|
1329
|
+
)
|
|
1330
|
+
useLiveNodeOverrides.getState().set(segmentId, { trim: pendingTrim })
|
|
1331
|
+
// Coalesce live merged-shell rebuilds during trim drag. The override
|
|
1332
|
+
// still updates every pointer move for local trim affordances, but the
|
|
1333
|
+
// full roof CSG only refreshes at a capped cadence instead of at raw
|
|
1334
|
+
// pointer-event frequency.
|
|
1335
|
+
scheduleDirtyMark()
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
updateFromPointer(event.clientX, event.clientY)
|
|
1339
|
+
|
|
1340
|
+
const cleanup = () => {
|
|
1341
|
+
clearPendingDirtyTimeout()
|
|
1342
|
+
window.removeEventListener('pointermove', onMove)
|
|
1343
|
+
window.removeEventListener('pointerup', onUp)
|
|
1344
|
+
window.removeEventListener('pointercancel', onCancel)
|
|
1345
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
1346
|
+
if (
|
|
1347
|
+
document.body.style.cursor === 'ew-resize' ||
|
|
1348
|
+
document.body.style.cursor === 'ns-resize' ||
|
|
1349
|
+
document.body.style.cursor === 'nwse-resize' ||
|
|
1350
|
+
document.body.style.cursor === 'nesw-resize' ||
|
|
1351
|
+
document.body.style.cursor === 'move'
|
|
1352
|
+
) {
|
|
1353
|
+
document.body.style.cursor = ''
|
|
1354
|
+
}
|
|
1355
|
+
useScene.temporal.getState().resume()
|
|
1356
|
+
useInteractionScope
|
|
1357
|
+
.getState()
|
|
1358
|
+
.endIf((scope) => scope.kind === 'handle-drag' && scope.nodeId === segmentId)
|
|
1359
|
+
useViewer.getState().setInputDragging(false)
|
|
1360
|
+
setDraggingSide(null)
|
|
1361
|
+
dragCleanupRef.current = null
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
1365
|
+
updateFromPointer(moveEvent.clientX, moveEvent.clientY)
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const onUp = () => {
|
|
1369
|
+
swallowNextClick()
|
|
1370
|
+
if (!trimEquals(pendingTrim, baseTrim)) {
|
|
1371
|
+
commitSegmentTrim(baseSegment, pendingTrim)
|
|
1372
|
+
}
|
|
1373
|
+
useLiveNodeOverrides.getState().clear(segmentId)
|
|
1374
|
+
flushDirtyMark()
|
|
1375
|
+
cleanup()
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const onCancel = () => {
|
|
1379
|
+
useLiveNodeOverrides.getState().clear(segmentId)
|
|
1380
|
+
flushDirtyMark()
|
|
1381
|
+
cleanup()
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Escape / ⌘Z abort the trim drag — capture phase so they win over the
|
|
1385
|
+
// global use-keyboard arms (⌘Z must never history-jump mid-gesture).
|
|
1386
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
1387
|
+
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
|
1388
|
+
e.preventDefault()
|
|
1389
|
+
e.stopPropagation()
|
|
1390
|
+
swallowNextClick()
|
|
1391
|
+
onCancel()
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
dragCleanupRef.current = cleanup
|
|
1395
|
+
window.addEventListener('pointermove', onMove)
|
|
1396
|
+
window.addEventListener('pointerup', onUp)
|
|
1397
|
+
window.addEventListener('pointercancel', onCancel)
|
|
1398
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const renderTrimPlane = (
|
|
1402
|
+
side: RoofTrimSide,
|
|
1403
|
+
position: [number, number, number],
|
|
1404
|
+
args: [number, number],
|
|
1405
|
+
rotation: [number, number, number] = [0, 0, 0],
|
|
1406
|
+
handles: readonly { side: RoofTrimSide; offsetX: number }[] = [{ side, offsetX: 0 }],
|
|
1407
|
+
showPlane = true,
|
|
1408
|
+
) => {
|
|
1409
|
+
const [planeWidth, planeHeight] = args
|
|
1410
|
+
const isHovered = handles.some((handle) => handle.side === hoveredSide)
|
|
1411
|
+
const railY = planeHeight / 2
|
|
1412
|
+
const railVisualHeight = Math.max(0.012, handleBaseScale * 0.022)
|
|
1413
|
+
const railVisualDepth = Math.max(0.01, handleBaseScale * 0.018)
|
|
1414
|
+
const railVisualLength = planeWidth + railVisualDepth * 2
|
|
1415
|
+
const capSize = Math.max(0.045, handleBaseScale * 0.085)
|
|
1416
|
+
const primaryHandle = handles[0] ?? { side, offsetX: 0 }
|
|
1417
|
+
const endpointHandles = handles.slice(1)
|
|
1418
|
+
|
|
1419
|
+
const renderRailHitTarget = (
|
|
1420
|
+
handle: { side: RoofTrimSide; offsetX: number },
|
|
1421
|
+
scale: [number, number, number],
|
|
1422
|
+
visual: 'rail' | 'cap',
|
|
1423
|
+
) => {
|
|
1424
|
+
const hovered = hoveredSide === handle.side
|
|
1425
|
+
const visualScale: [number, number, number] =
|
|
1426
|
+
visual === 'rail' ? scale : [capSize, capSize, capSize]
|
|
1427
|
+
const hitScale: [number, number, number] =
|
|
1428
|
+
visual === 'rail'
|
|
1429
|
+
? [scale[0], TRIM_RAIL_HIT_HEIGHT, TRIM_RAIL_HIT_DEPTH]
|
|
1430
|
+
: [TRIM_CAP_HIT_SIZE, TRIM_CAP_HIT_SIZE, TRIM_CAP_HIT_SIZE]
|
|
1431
|
+
const resetCorner = getDiagonalResetCorner(handle.side)
|
|
1432
|
+
return (
|
|
1433
|
+
<group key={handle.side} position={[handle.offsetX, railY, TRIM_RAIL_SURFACE_OFFSET]}>
|
|
1434
|
+
<mesh
|
|
1435
|
+
geometry={visual === 'rail' ? TRIM_UNIT_RAIL_GEOMETRY : TRIM_UNIT_RAIL_CAP_GEOMETRY}
|
|
1436
|
+
layers={EDITOR_LAYER}
|
|
1437
|
+
material={
|
|
1438
|
+
visual === 'rail'
|
|
1439
|
+
? hovered
|
|
1440
|
+
? trimRailHoverMaterial
|
|
1441
|
+
: trimRailMaterial
|
|
1442
|
+
: hovered
|
|
1443
|
+
? trimCapHoverMaterial
|
|
1444
|
+
: trimCapMaterial
|
|
1445
|
+
}
|
|
1446
|
+
raycast={makeExpandedTrimRaycast(visualScale, hitScale)}
|
|
1447
|
+
onDoubleClick={
|
|
1448
|
+
resetCorner ? (event) => resetDiagonalTrim(handle.side, event) : undefined
|
|
1449
|
+
}
|
|
1450
|
+
onPointerDown={(event) => startDrag(handle.side, event)}
|
|
1451
|
+
onPointerEnter={(event) => {
|
|
1452
|
+
event.stopPropagation()
|
|
1453
|
+
setHoveredSide(handle.side)
|
|
1454
|
+
document.body.style.cursor = getTrimCursor(handle.side)
|
|
1455
|
+
}}
|
|
1456
|
+
onPointerLeave={(event) => {
|
|
1457
|
+
event.stopPropagation()
|
|
1458
|
+
if (!dragCleanupRef.current) {
|
|
1459
|
+
setHoveredSide((current) => (current === handle.side ? null : current))
|
|
1460
|
+
document.body.style.cursor = ''
|
|
1461
|
+
}
|
|
1462
|
+
}}
|
|
1463
|
+
renderOrder={TRIM_RAIL_RENDER_ORDER}
|
|
1464
|
+
scale={visualScale}
|
|
1465
|
+
/>
|
|
1466
|
+
</group>
|
|
1467
|
+
)
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
return (
|
|
1471
|
+
<group
|
|
1472
|
+
key={side}
|
|
1473
|
+
layers={EDITOR_LAYER}
|
|
1474
|
+
position={position}
|
|
1475
|
+
rotation={rotation}
|
|
1476
|
+
renderOrder={TRIM_RAIL_RENDER_ORDER}
|
|
1477
|
+
>
|
|
1478
|
+
{showPlane ? (
|
|
1479
|
+
<mesh
|
|
1480
|
+
geometry={TRIM_UNIT_PLANE_GEOMETRY}
|
|
1481
|
+
layers={EDITOR_LAYER}
|
|
1482
|
+
material={isHovered ? trimPlaneHoverMaterial : trimPlaneMaterial}
|
|
1483
|
+
raycast={() => null}
|
|
1484
|
+
renderOrder={TRIM_PLANE_RENDER_ORDER}
|
|
1485
|
+
scale={[planeWidth, planeHeight, 1]}
|
|
1486
|
+
/>
|
|
1487
|
+
) : null}
|
|
1488
|
+
|
|
1489
|
+
{renderRailHitTarget(
|
|
1490
|
+
primaryHandle,
|
|
1491
|
+
[railVisualLength, railVisualHeight, railVisualDepth],
|
|
1492
|
+
'rail',
|
|
1493
|
+
)}
|
|
1494
|
+
{endpointHandles.map((handle) =>
|
|
1495
|
+
renderRailHitTarget(handle, [capSize, capSize, capSize], 'cap'),
|
|
1496
|
+
)}
|
|
1497
|
+
</group>
|
|
1498
|
+
)
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
const renderDiagonalAddHandle = (side: DiagonalTrimSide) => {
|
|
1502
|
+
if (maxDiagonalTrim <= 0) return null
|
|
1503
|
+
|
|
1504
|
+
let position: [number, number, number]
|
|
1505
|
+
let xDir = 1
|
|
1506
|
+
let zDir = 1
|
|
1507
|
+
switch (side) {
|
|
1508
|
+
case 'frontLeft':
|
|
1509
|
+
position = [leftX, handleY, frontZ]
|
|
1510
|
+
xDir = 1
|
|
1511
|
+
zDir = -1
|
|
1512
|
+
break
|
|
1513
|
+
case 'frontRight':
|
|
1514
|
+
position = [rightX, handleY, frontZ]
|
|
1515
|
+
xDir = -1
|
|
1516
|
+
zDir = -1
|
|
1517
|
+
break
|
|
1518
|
+
case 'backLeft':
|
|
1519
|
+
position = [leftX, handleY, backZ]
|
|
1520
|
+
xDir = 1
|
|
1521
|
+
zDir = 1
|
|
1522
|
+
break
|
|
1523
|
+
case 'backRight':
|
|
1524
|
+
position = [rightX, handleY, backZ]
|
|
1525
|
+
xDir = -1
|
|
1526
|
+
zDir = 1
|
|
1527
|
+
break
|
|
1528
|
+
default:
|
|
1529
|
+
return null
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const hovered = hoveredSide === side
|
|
1533
|
+
const addSize = Math.max(0.055, handleBaseScale * 0.1)
|
|
1534
|
+
const addVisualScale: [number, number, number] = [addSize, addSize, addSize]
|
|
1535
|
+
const addHitScale: [number, number, number] = [
|
|
1536
|
+
TRIM_CAP_HIT_SIZE,
|
|
1537
|
+
TRIM_CAP_HIT_SIZE,
|
|
1538
|
+
TRIM_CAP_HIT_SIZE,
|
|
1539
|
+
]
|
|
1540
|
+
const bracketLength = Math.min(0.55, Math.max(0.28, maxDiagonalTrim * 0.22))
|
|
1541
|
+
const bracketHeight = Math.max(0.012, handleBaseScale * 0.022)
|
|
1542
|
+
const bracketDepth = Math.max(0.01, handleBaseScale * 0.018)
|
|
1543
|
+
const bracketArmLength = bracketLength + bracketDepth
|
|
1544
|
+
const bracketVisualScale: [number, number, number] = [
|
|
1545
|
+
bracketArmLength,
|
|
1546
|
+
bracketHeight,
|
|
1547
|
+
bracketDepth,
|
|
1548
|
+
]
|
|
1549
|
+
const bracketHitScale: [number, number, number] = [
|
|
1550
|
+
bracketArmLength,
|
|
1551
|
+
TRIM_RAIL_HIT_HEIGHT,
|
|
1552
|
+
TRIM_RAIL_HIT_DEPTH,
|
|
1553
|
+
]
|
|
1554
|
+
const previewAmount = getStarterDiagonalTrim(liveSegment, trim)
|
|
1555
|
+
|
|
1556
|
+
let previewStart: [number, number]
|
|
1557
|
+
let previewEnd: [number, number]
|
|
1558
|
+
switch (side) {
|
|
1559
|
+
case 'frontLeft':
|
|
1560
|
+
previewStart = [leftX + previewAmount, frontZ]
|
|
1561
|
+
previewEnd = [leftX, frontZ - previewAmount]
|
|
1562
|
+
break
|
|
1563
|
+
case 'frontRight':
|
|
1564
|
+
previewStart = [rightX, frontZ - previewAmount]
|
|
1565
|
+
previewEnd = [rightX - previewAmount, frontZ]
|
|
1566
|
+
break
|
|
1567
|
+
case 'backLeft':
|
|
1568
|
+
previewStart = [leftX, backZ + previewAmount]
|
|
1569
|
+
previewEnd = [leftX + previewAmount, backZ]
|
|
1570
|
+
break
|
|
1571
|
+
case 'backRight':
|
|
1572
|
+
previewStart = [rightX - previewAmount, backZ]
|
|
1573
|
+
previewEnd = [rightX, backZ + previewAmount]
|
|
1574
|
+
break
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const [previewRailStart, previewRailEnd] = getDiagonalRailLine(side, previewStart, previewEnd)
|
|
1578
|
+
const previewDx = previewRailEnd[0] - previewRailStart[0]
|
|
1579
|
+
const previewDz = previewRailEnd[1] - previewRailStart[1]
|
|
1580
|
+
const previewWidth = Math.hypot(previewDx, previewDz)
|
|
1581
|
+
const previewYaw = Math.atan2(-previewDz, previewDx)
|
|
1582
|
+
const handlePointerEnter = (event: ThreeEvent<PointerEvent>) => {
|
|
1583
|
+
event.stopPropagation()
|
|
1584
|
+
setHoveredSide(side)
|
|
1585
|
+
document.body.style.cursor = getTrimCursor(side)
|
|
1586
|
+
}
|
|
1587
|
+
const handlePointerLeave = (event: ThreeEvent<PointerEvent>) => {
|
|
1588
|
+
event.stopPropagation()
|
|
1589
|
+
if (!dragCleanupRef.current) {
|
|
1590
|
+
setHoveredSide((current) => (current === side ? null : current))
|
|
1591
|
+
document.body.style.cursor = ''
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
return (
|
|
1596
|
+
<group key={`${side}-add`} layers={EDITOR_LAYER}>
|
|
1597
|
+
{hovered && previewWidth > 0 ? (
|
|
1598
|
+
<group
|
|
1599
|
+
position={[
|
|
1600
|
+
(previewRailStart[0] + previewRailEnd[0]) / 2,
|
|
1601
|
+
handleY / 2,
|
|
1602
|
+
(previewRailStart[1] + previewRailEnd[1]) / 2,
|
|
1603
|
+
]}
|
|
1604
|
+
rotation={[0, previewYaw, 0]}
|
|
1605
|
+
>
|
|
1606
|
+
<mesh
|
|
1607
|
+
geometry={TRIM_UNIT_RAIL_GEOMETRY}
|
|
1608
|
+
layers={EDITOR_LAYER}
|
|
1609
|
+
material={trimDiagonalPreviewRailMaterial}
|
|
1610
|
+
position={[0, handleY / 2, TRIM_RAIL_SURFACE_OFFSET]}
|
|
1611
|
+
raycast={() => null}
|
|
1612
|
+
renderOrder={TRIM_RAIL_RENDER_ORDER}
|
|
1613
|
+
scale={[previewWidth + bracketDepth * 2, bracketHeight, bracketDepth]}
|
|
1614
|
+
/>
|
|
1615
|
+
</group>
|
|
1616
|
+
) : null}
|
|
1617
|
+
|
|
1618
|
+
<mesh
|
|
1619
|
+
geometry={TRIM_UNIT_RAIL_GEOMETRY}
|
|
1620
|
+
layers={EDITOR_LAYER}
|
|
1621
|
+
material={hovered ? trimRailHoverMaterial : trimRailMaterial}
|
|
1622
|
+
onDoubleClick={(event) => resetDiagonalTrim(side, event)}
|
|
1623
|
+
onPointerDown={(event) => startDrag(side, event)}
|
|
1624
|
+
onPointerEnter={handlePointerEnter}
|
|
1625
|
+
onPointerLeave={handlePointerLeave}
|
|
1626
|
+
position={[position[0] + (xDir * bracketArmLength) / 2, position[1], position[2]]}
|
|
1627
|
+
raycast={makeExpandedTrimRaycast(bracketVisualScale, bracketHitScale)}
|
|
1628
|
+
renderOrder={TRIM_RAIL_RENDER_ORDER}
|
|
1629
|
+
scale={bracketVisualScale}
|
|
1630
|
+
/>
|
|
1631
|
+
<mesh
|
|
1632
|
+
geometry={TRIM_UNIT_RAIL_GEOMETRY}
|
|
1633
|
+
layers={EDITOR_LAYER}
|
|
1634
|
+
material={hovered ? trimRailHoverMaterial : trimRailMaterial}
|
|
1635
|
+
onDoubleClick={(event) => resetDiagonalTrim(side, event)}
|
|
1636
|
+
onPointerDown={(event) => startDrag(side, event)}
|
|
1637
|
+
onPointerEnter={handlePointerEnter}
|
|
1638
|
+
onPointerLeave={handlePointerLeave}
|
|
1639
|
+
position={[position[0], position[1], position[2] + (zDir * bracketArmLength) / 2]}
|
|
1640
|
+
raycast={makeExpandedTrimRaycast(bracketVisualScale, bracketHitScale)}
|
|
1641
|
+
renderOrder={TRIM_RAIL_RENDER_ORDER}
|
|
1642
|
+
rotation={[0, zDir > 0 ? -Math.PI / 2 : Math.PI / 2, 0]}
|
|
1643
|
+
scale={bracketVisualScale}
|
|
1644
|
+
/>
|
|
1645
|
+
<mesh
|
|
1646
|
+
geometry={TRIM_UNIT_ADD_GEOMETRY}
|
|
1647
|
+
layers={EDITOR_LAYER}
|
|
1648
|
+
material={hovered ? trimAddHoverMaterial : trimAddMaterial}
|
|
1649
|
+
onDoubleClick={(event) => resetDiagonalTrim(side, event)}
|
|
1650
|
+
onPointerDown={(event) => startDrag(side, event)}
|
|
1651
|
+
onPointerEnter={handlePointerEnter}
|
|
1652
|
+
onPointerLeave={handlePointerLeave}
|
|
1653
|
+
position={position}
|
|
1654
|
+
raycast={makeExpandedTrimRaycast(addVisualScale, addHitScale)}
|
|
1655
|
+
renderOrder={TRIM_RAIL_RENDER_ORDER}
|
|
1656
|
+
scale={addVisualScale}
|
|
1657
|
+
/>
|
|
1658
|
+
</group>
|
|
1659
|
+
)
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const renderDiagonalTrimPlane = (side: DiagonalTrimSide, xAmount: number, zAmount: number) => {
|
|
1663
|
+
if (maxDiagonalTrim <= 0) {
|
|
1664
|
+
return null
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (!(xAmount > 0 && zAmount > 0)) {
|
|
1668
|
+
return renderDiagonalAddHandle(side)
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
const displayX = xAmount
|
|
1672
|
+
const displayZ = zAmount
|
|
1673
|
+
if (!(displayX > 0 && displayZ > 0)) return null
|
|
1674
|
+
|
|
1675
|
+
let start: [number, number]
|
|
1676
|
+
let end: [number, number]
|
|
1677
|
+
let xOffset = 0
|
|
1678
|
+
let zOffset = 0
|
|
1679
|
+
const [xSide, zSide] = getDiagonalAxisKeys(side)
|
|
1680
|
+
switch (side) {
|
|
1681
|
+
case 'frontLeft':
|
|
1682
|
+
start = [leftX + displayX, frontZ]
|
|
1683
|
+
end = [leftX, frontZ - displayZ]
|
|
1684
|
+
xOffset = -1
|
|
1685
|
+
zOffset = 1
|
|
1686
|
+
break
|
|
1687
|
+
case 'frontRight':
|
|
1688
|
+
start = [rightX, frontZ - displayZ]
|
|
1689
|
+
end = [rightX - displayX, frontZ]
|
|
1690
|
+
zOffset = -1
|
|
1691
|
+
xOffset = 1
|
|
1692
|
+
break
|
|
1693
|
+
case 'backLeft':
|
|
1694
|
+
start = [leftX, backZ + displayZ]
|
|
1695
|
+
end = [leftX + displayX, backZ]
|
|
1696
|
+
zOffset = -1
|
|
1697
|
+
xOffset = 1
|
|
1698
|
+
break
|
|
1699
|
+
case 'backRight':
|
|
1700
|
+
start = [rightX - displayX, backZ]
|
|
1701
|
+
end = [rightX, backZ + displayZ]
|
|
1702
|
+
xOffset = -1
|
|
1703
|
+
zOffset = 1
|
|
1704
|
+
break
|
|
1705
|
+
default:
|
|
1706
|
+
return null
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
const [railStart, railEnd] = getDiagonalRailLine(side, start, end)
|
|
1710
|
+
const dx = railEnd[0] - railStart[0]
|
|
1711
|
+
const dz = railEnd[1] - railStart[1]
|
|
1712
|
+
const width = Math.hypot(dx, dz)
|
|
1713
|
+
const yaw = Math.atan2(-dz, dx)
|
|
1714
|
+
return renderTrimPlane(
|
|
1715
|
+
side,
|
|
1716
|
+
[(railStart[0] + railEnd[0]) / 2, handleY / 2, (railStart[1] + railEnd[1]) / 2],
|
|
1717
|
+
[width, handleY],
|
|
1718
|
+
[0, yaw, 0],
|
|
1719
|
+
[
|
|
1720
|
+
{ side, offsetX: 0 },
|
|
1721
|
+
{ side: xSide, offsetX: (width / 2) * xOffset },
|
|
1722
|
+
{ side: zSide, offsetX: (width / 2) * zOffset },
|
|
1723
|
+
],
|
|
1724
|
+
false,
|
|
1725
|
+
)
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
return (
|
|
1729
|
+
<group ref={groupRef}>
|
|
1730
|
+
{sectionPlanes.length > 0 ? (
|
|
1731
|
+
<SectionCut planes={sectionPlanes} segment={liveSegment} />
|
|
1732
|
+
) : null}
|
|
1733
|
+
{renderTrimPlane(
|
|
1734
|
+
'left',
|
|
1735
|
+
[visualLeftX, handleY / 2, visibleCenterZ],
|
|
1736
|
+
[visibleDepth, handleY],
|
|
1737
|
+
[0, Math.PI / 2, 0],
|
|
1738
|
+
)}
|
|
1739
|
+
{renderTrimPlane(
|
|
1740
|
+
'right',
|
|
1741
|
+
[visualRightX, handleY / 2, visibleCenterZ],
|
|
1742
|
+
[visibleDepth, handleY],
|
|
1743
|
+
[0, Math.PI / 2, 0],
|
|
1744
|
+
)}
|
|
1745
|
+
{renderTrimPlane(
|
|
1746
|
+
'front',
|
|
1747
|
+
[visibleCenterX, handleY / 2, visualFrontZ],
|
|
1748
|
+
[visibleWidth, handleY],
|
|
1749
|
+
)}
|
|
1750
|
+
{renderTrimPlane('back', [visibleCenterX, handleY / 2, visualBackZ], [visibleWidth, handleY])}
|
|
1751
|
+
{renderDiagonalTrimPlane('frontLeft', trim.frontLeftX, trim.frontLeftZ)}
|
|
1752
|
+
{renderDiagonalTrimPlane('frontRight', trim.frontRightX, trim.frontRightZ)}
|
|
1753
|
+
{renderDiagonalTrimPlane('backLeft', trim.backLeftX, trim.backLeftZ)}
|
|
1754
|
+
{renderDiagonalTrimPlane('backRight', trim.backRightX, trim.backRightZ)}
|
|
1755
|
+
</group>
|
|
1756
|
+
)
|
|
1757
|
+
}
|
|
4
1758
|
|
|
5
1759
|
/**
|
|
6
1760
|
* Imperatively toggles the Three.js visibility of roof objects based on the
|
|
7
1761
|
* editor selection — without causing React re-renders in RoofRenderer.
|
|
8
1762
|
*
|
|
9
|
-
*
|
|
10
|
-
* - merged-roof mesh
|
|
11
|
-
*
|
|
12
|
-
*
|
|
1763
|
+
* Full edit-mode (segment selected):
|
|
1764
|
+
* - merged-roof mesh stays VISIBLE — it rebuilds live from each segment's
|
|
1765
|
+
* trim override, so the edited cutaway matches the clean commit instead of
|
|
1766
|
+
* exposing the per-segment meshes' abutting end-cap faces
|
|
1767
|
+
* - segments-wrapper group stays hidden (handles render from RoofTrimHandles)
|
|
1768
|
+
* - all children are marked dirty so RoofSystem rebuilds the merged shell
|
|
1769
|
+
*
|
|
1770
|
+
* Accessory-reveal mode (a dormer/chimney/etc. hosted on a segment is selected):
|
|
1771
|
+
* - merged-roof mesh stays visible (we don't want the appearance to jump)
|
|
1772
|
+
* - segments-wrapper group is shown ANYWAY so anything portaled into a
|
|
1773
|
+
* segment's registered mesh (e.g. dormer in-world handle arrows that
|
|
1774
|
+
* don't use `portal: 'grandparent'`) is no longer inheriting the
|
|
1775
|
+
* wrapper's hidden flag
|
|
1776
|
+
* - segment placeholder geometry is empty, so revealing the wrapper has
|
|
1777
|
+
* no visible cost beyond letting the handle arrows render
|
|
13
1778
|
*
|
|
14
|
-
* When deselected:
|
|
15
|
-
* - merged-roof mesh is shown
|
|
16
|
-
* - segments-wrapper group is hidden
|
|
1779
|
+
* When deselected: merged-roof shown, segments-wrapper hidden.
|
|
17
1780
|
*/
|
|
18
1781
|
export const RoofEditSystem = () => {
|
|
19
1782
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
1783
|
+
const movingNode = useMovingNode()
|
|
20
1784
|
const prevActiveRoofIds = useRef(new Set<string>())
|
|
1785
|
+
const prevMovingRoofIds = useRef(new Set<string>())
|
|
1786
|
+
const prevRevealRoofIds = useRef(new Set<string>())
|
|
21
1787
|
|
|
22
1788
|
useEffect(() => {
|
|
23
1789
|
const nodes = useScene.getState().nodes
|
|
24
1790
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// material appearance does not jump between merged and per-segment meshes.
|
|
1791
|
+
// Roofs where a segment itself is selected -> full edit mode (hide
|
|
1792
|
+
// merged, show wrapper).
|
|
28
1793
|
const activeRoofIds = new Set<string>()
|
|
1794
|
+
// Roofs where an accessory (dormer/chimney/etc.) is selected -> only
|
|
1795
|
+
// reveal the wrapper so handle portals into the segment mesh become
|
|
1796
|
+
// visible. Merged stays on.
|
|
1797
|
+
const revealRoofIds = new Set<string>()
|
|
1798
|
+
// Roofs whose selected segment is currently being moved in 3D. During this
|
|
1799
|
+
// transient state we reveal the wrapper so the moving segment mesh is
|
|
1800
|
+
// visible and hide the merged roof to avoid the duplicate shell fighting it.
|
|
1801
|
+
const movingRoofIds = new Set<string>()
|
|
1802
|
+
|
|
29
1803
|
for (const id of selectedIds) {
|
|
30
1804
|
const node = nodes[id as AnyNodeId]
|
|
31
1805
|
if (!node) continue
|
|
32
1806
|
if (node.type === 'roof-segment' && node.parentId) {
|
|
33
1807
|
activeRoofIds.add(node.parentId)
|
|
1808
|
+
continue
|
|
34
1809
|
}
|
|
1810
|
+
// Walk up one level: if the parent is a roof-segment, this is a
|
|
1811
|
+
// hosted accessory and we want to reveal its grandparent roof's
|
|
1812
|
+
// wrapper. Two-step lookup keeps it scoped to roof children
|
|
1813
|
+
// without enumerating all accessory kinds.
|
|
1814
|
+
if (!node.parentId) continue
|
|
1815
|
+
const parent = nodes[node.parentId as AnyNodeId]
|
|
1816
|
+
if (parent?.type === 'roof-segment' && parent.parentId) {
|
|
1817
|
+
revealRoofIds.add(parent.parentId)
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
if (movingNode?.type === 'roof-segment' && movingNode.parentId) {
|
|
1822
|
+
movingRoofIds.add(movingNode.parentId)
|
|
35
1823
|
}
|
|
36
1824
|
|
|
37
|
-
//
|
|
38
|
-
const roofIdsToUpdate = new Set([
|
|
1825
|
+
// Union of roofs that need ANY state change this tick.
|
|
1826
|
+
const roofIdsToUpdate = new Set([
|
|
1827
|
+
...activeRoofIds,
|
|
1828
|
+
...movingRoofIds,
|
|
1829
|
+
...revealRoofIds,
|
|
1830
|
+
...prevActiveRoofIds.current,
|
|
1831
|
+
...prevMovingRoofIds.current,
|
|
1832
|
+
...prevRevealRoofIds.current,
|
|
1833
|
+
])
|
|
39
1834
|
|
|
40
1835
|
for (const roofId of roofIdsToUpdate) {
|
|
41
1836
|
const group = sceneRegistry.nodes.get(roofId)
|
|
@@ -44,26 +1839,60 @@ export const RoofEditSystem = () => {
|
|
|
44
1839
|
const mergedMesh = group.getObjectByName('merged-roof')
|
|
45
1840
|
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
|
46
1841
|
const isActive = activeRoofIds.has(roofId)
|
|
1842
|
+
const isMoving = movingRoofIds.has(roofId)
|
|
1843
|
+
const isReveal = revealRoofIds.has(roofId)
|
|
47
1844
|
|
|
48
|
-
|
|
49
|
-
|
|
1845
|
+
// Keep the clean merged shell visible during trim editing too (not just
|
|
1846
|
+
// when deselected). The merged shell rebuilds live from each segment's
|
|
1847
|
+
// trim override (RoofSystem reads getEffectiveNode), so the dragged
|
|
1848
|
+
// cutaway matches the commit. Showing the individual per-segment meshes
|
|
1849
|
+
// instead would expose their abutting end-cap faces (the white planes the
|
|
1850
|
+
// merged union removes) — exactly what the commit doesn't show.
|
|
1851
|
+
if (mergedMesh) mergedMesh.visible = !isMoving
|
|
1852
|
+
if (segmentsWrapper) segmentsWrapper.visible = isReveal || isMoving
|
|
50
1853
|
|
|
51
1854
|
const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined
|
|
52
1855
|
if (roofNode?.children?.length) {
|
|
53
1856
|
const wasActive = prevActiveRoofIds.current.has(roofId)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
1857
|
+
const wasMoving = prevMovingRoofIds.current.has(roofId)
|
|
1858
|
+
const wasReveal = prevRevealRoofIds.current.has(roofId)
|
|
1859
|
+
if (isActive !== wasActive || isMoving !== wasMoving) {
|
|
1860
|
+
// Entering / exiting full edit mode: rebuild segment / merged
|
|
1861
|
+
// geometries. Segment-move reveal uses the same rebuild so any
|
|
1862
|
+
// wrapper mesh previously stripped to an empty placeholder is
|
|
1863
|
+
// restored before the drag begins.
|
|
57
1864
|
const { markDirty } = useScene.getState()
|
|
58
1865
|
for (const childId of roofNode.children) {
|
|
59
1866
|
markDirty(childId as AnyNodeId)
|
|
60
1867
|
}
|
|
61
1868
|
}
|
|
1869
|
+
// Entering reveal mode (and NOT also full-edit, which already
|
|
1870
|
+
// owns its own rebuild path): strip each segment mesh back to
|
|
1871
|
+
// an empty placeholder so the wrapper-now-visible doesn't
|
|
1872
|
+
// re-show stale CSG geometry from a previous segment edit.
|
|
1873
|
+
// Without this, the host segment's CSG cut renders ON TOP of
|
|
1874
|
+
// the merged-roof, doubling the dormer's cut shape and
|
|
1875
|
+
// bleeding the host wall material through the dormer body.
|
|
1876
|
+
if (isReveal && !isActive && !wasReveal && segmentsWrapper) {
|
|
1877
|
+
for (const child of segmentsWrapper.children) {
|
|
1878
|
+
const mesh = child as THREE.Mesh
|
|
1879
|
+
if (!mesh.isMesh) continue
|
|
1880
|
+
mesh.geometry?.dispose()
|
|
1881
|
+
mesh.geometry = makeEmptySegmentGeometry()
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
62
1884
|
}
|
|
63
1885
|
}
|
|
64
1886
|
|
|
65
1887
|
prevActiveRoofIds.current = activeRoofIds
|
|
66
|
-
|
|
1888
|
+
prevMovingRoofIds.current = movingRoofIds
|
|
1889
|
+
prevRevealRoofIds.current = revealRoofIds
|
|
1890
|
+
}, [movingNode, selectedIds])
|
|
67
1891
|
|
|
68
|
-
return
|
|
1892
|
+
return (
|
|
1893
|
+
<>
|
|
1894
|
+
<HoveredRoofSegmentOutlineProxy />
|
|
1895
|
+
<RoofTrimHandles />
|
|
1896
|
+
</>
|
|
1897
|
+
)
|
|
69
1898
|
}
|