@pascal-app/editor 1.0.3 → 1.0.4
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 +11 -9
- package/src/components/editor/action-menu-button.tsx +83 -0
- package/src/components/editor/bake-thumbnail.tsx +315 -4
- package/src/components/editor/capture-level-picker.tsx +72 -0
- package/src/components/editor/custom-camera-controls.tsx +202 -57
- package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
- package/src/components/editor/duplicate-node.ts +244 -0
- package/src/components/editor/editor-layout-mobile.tsx +36 -25
- package/src/components/editor/editor-layout-v2.tsx +131 -58
- package/src/components/editor/export-manager.tsx +34 -8
- package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
- package/src/components/editor/first-person/build-collider-world.ts +6 -2
- package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
- package/src/components/editor/first-person/mechanism-targets.ts +36 -0
- package/src/components/editor/first-person-controls.tsx +205 -2
- package/src/components/editor/floating-action-menu.tsx +63 -207
- package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
- package/src/components/editor/floor-aware-camera-controls.ts +324 -0
- package/src/components/editor/floor-openings-overlay.tsx +243 -0
- package/src/components/editor/floor-region-controls.tsx +846 -0
- package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
- package/src/components/editor/floor-surface.test.ts +165 -0
- package/src/components/editor/floor-surface.ts +182 -0
- package/src/components/editor/floorplan-background-selection.test.ts +11 -2
- package/src/components/editor/floorplan-background-selection.ts +1 -3
- package/src/components/editor/floorplan-panel.tsx +344 -342
- package/src/components/editor/footprint-height-handle.tsx +204 -0
- package/src/components/editor/group-actions.ts +54 -2
- package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
- package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
- package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
- package/src/components/editor/handles/use-handle-drag.ts +269 -197
- package/src/components/editor/index.tsx +294 -169
- package/src/components/editor/join-walls-pill.tsx +44 -0
- package/src/components/editor/node-action-menu.tsx +68 -70
- package/src/components/editor/node-arrow-handles.tsx +25 -6
- package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
- package/src/components/editor/open-wall-ends-hint.tsx +89 -0
- package/src/components/editor/quick-measurement-hud.tsx +3 -2
- package/src/components/editor/registry-action-contributions.test.tsx +63 -0
- package/src/components/editor/registry-action-contributions.tsx +80 -1
- package/src/components/editor/room-controls.tsx +847 -0
- package/src/components/editor/room-floor-highlight.tsx +128 -0
- package/src/components/editor/room-handles-2d.tsx +354 -0
- package/src/components/editor/room-handles.tsx +675 -0
- package/src/components/editor/room-highlight.tsx +225 -0
- package/src/components/editor/room-labels.test.ts +357 -0
- package/src/components/editor/room-labels.tsx +990 -0
- package/src/components/editor/room-transform-ghost.tsx +256 -0
- package/src/components/editor/room-webgpu-materials.test.ts +27 -0
- package/src/components/editor/selection-manager.tsx +451 -317
- package/src/components/editor/slab-hole-highlights.tsx +3 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
- package/src/components/editor/snapshot-capture.test.ts +52 -4
- package/src/components/editor/snapshot-capture.ts +30 -5
- package/src/components/editor/thumbnail-generator.test.ts +308 -0
- package/src/components/editor/thumbnail-generator.tsx +1035 -229
- package/src/components/editor/use-floorplan-background-placement.ts +31 -11
- package/src/components/editor/vector-edge-extractor.tsx +121 -0
- package/src/components/editor/views/host-view.tsx +61 -0
- package/src/components/editor/views/plugins-view.tsx +670 -0
- package/src/components/editor/views/scene-region.tsx +59 -0
- package/src/components/editor/views/use-editor-views.ts +57 -0
- package/src/components/editor/views/view-bar.tsx +220 -0
- package/src/components/editor/wall-justification.test.ts +140 -0
- package/src/components/editor/wall-move-side-handles.tsx +43 -142
- package/src/components/editor/wall-push-arrow.tsx +92 -0
- package/src/components/editor/wall-region-handles.tsx +509 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
- package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
- package/src/components/editor-2d/svg-paths.ts +12 -15
- package/src/components/procedural-items/inspector.tsx +40 -15
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
- package/src/components/systems/zone/zone-system.test.ts +47 -0
- package/src/components/systems/zone/zone-system.tsx +29 -68
- package/src/components/tools/item/placement-history.test.tsx +474 -0
- package/src/components/tools/item/placement-strategies.ts +8 -4
- package/src/components/tools/item/use-draft-node.test.tsx +432 -1
- package/src/components/tools/item/use-draft-node.ts +166 -95
- package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
- package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
- package/src/components/tools/select/box-select-tool.tsx +2 -12
- package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
- package/src/components/tools/select/select-candidates.test.ts +37 -4
- package/src/components/tools/select/select-candidates.ts +15 -11
- package/src/components/tools/shared/cursor-sphere.tsx +36 -12
- package/src/components/tools/site/site-boundary-editor.tsx +1 -2
- package/src/components/tools/stair/stair-defaults.ts +0 -3
- package/src/components/tools/stair/stair-tool.tsx +90 -42
- package/src/components/tools/tool-manager.tsx +46 -20
- package/src/components/tools/wall/wall-drafting.test.ts +19 -4
- package/src/components/tools/zone/zone-tool.tsx +4 -12
- package/src/components/ui/action-menu/action-button.tsx +3 -0
- package/src/components/ui/action-menu/camera-actions.tsx +2 -1
- package/src/components/ui/action-menu/control-modes.tsx +2 -35
- package/src/components/ui/action-menu/measurement-control.tsx +3 -10
- package/src/components/ui/build-panel/build-panel.tsx +235 -0
- package/src/components/ui/command-palette/editor-commands.tsx +4 -30
- package/src/components/ui/command-palette/index.tsx +5 -24
- package/src/components/ui/controls/finish-swatch.tsx +110 -0
- package/src/components/ui/controls/material-paint-panel.tsx +97 -26
- package/src/components/ui/controls/material-picker.tsx +4 -1
- package/src/components/ui/controls/scene-material-list.tsx +4 -32
- package/src/components/ui/controls/select-control.tsx +65 -0
- package/src/components/ui/floating-level-selector.tsx +9 -14
- package/src/components/ui/helpers/building-helper.tsx +4 -1
- package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
- package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
- package/src/components/ui/helpers/helper-manager.tsx +325 -14
- package/src/components/ui/helpers/item-helper.tsx +8 -0
- package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
- package/src/components/ui/icon-ref.tsx +45 -1
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/node-icon-image.tsx +16 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
- package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
- package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
- package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
- package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
- package/src/components/ui/panels/multi-field-value.test.ts +26 -3
- package/src/components/ui/panels/multi-field-value.ts +6 -3
- package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
- package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
- package/src/components/ui/panels/node-display.ts +8 -3
- package/src/components/ui/panels/panel-manager.tsx +103 -68
- package/src/components/ui/panels/panel-wrapper.tsx +78 -36
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -4
- package/src/components/ui/panels/reference-panel.tsx +2 -3
- package/src/components/ui/panels/room-construction-rows.tsx +774 -0
- package/src/components/ui/panels/room-name-options.test.ts +55 -0
- package/src/components/ui/panels/room-name-options.ts +47 -0
- package/src/components/ui/panels/room-openings-field.tsx +110 -0
- package/src/components/ui/panels/room-panel.tsx +725 -0
- package/src/components/ui/panels/shape-choice.tsx +58 -0
- package/src/components/ui/panels/wall-region-list.tsx +109 -0
- package/src/components/ui/primitives/color-dot.tsx +36 -24
- package/src/components/ui/primitives/sidebar.tsx +2 -0
- package/src/components/ui/right-stack.test.ts +29 -0
- package/src/components/ui/right-stack.tsx +145 -0
- package/src/components/ui/sidebar/icon-rail.tsx +5 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
- package/src/components/ui/sidebar/tab-bar.tsx +10 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
- package/src/components/viewer/floorplan-compass-button.tsx +9 -1
- package/src/components/viewer/floorplan-preview.tsx +5 -6
- package/src/components/viewer/viewer-hover-label.tsx +34 -0
- package/src/components/viewer/viewer-rooms.tsx +175 -0
- package/src/components/viewer/viewer-scene-header.tsx +181 -145
- package/src/components/viewer/viewer-selection-manager.tsx +124 -0
- package/src/components/viewer/viewer-stage.tsx +2 -2
- package/src/components/viewer-zone-system.tsx +3 -8
- package/src/hooks/use-auto-save.integration.test.tsx +123 -9
- package/src/hooks/use-auto-save.ts +34 -8
- package/src/hooks/use-keyboard.test.ts +178 -3
- package/src/hooks/use-keyboard.ts +89 -71
- package/src/hooks/use-open-wall-ends.test.ts +99 -0
- package/src/hooks/use-open-wall-ends.ts +238 -0
- package/src/hooks/use-placement-notice.test.ts +48 -0
- package/src/hooks/use-placement-notice.ts +42 -0
- package/src/hooks/use-selected-room.ts +143 -0
- package/src/index.tsx +246 -39
- package/src/lib/ceiling-edit-session.test.ts +321 -0
- package/src/lib/ceiling-edit-session.ts +206 -0
- package/src/lib/ceiling-region-session.test.ts +179 -0
- package/src/lib/continuation.test.ts +19 -1
- package/src/lib/continuation.ts +25 -4
- package/src/lib/door-interaction.ts +14 -0
- package/src/lib/editor-level-display.test.ts +96 -0
- package/src/lib/editor-level-display.ts +48 -0
- package/src/lib/editor-views.ts +144 -0
- package/src/lib/export-geometry-wait.test.ts +90 -0
- package/src/lib/floor-edit-session.ts +153 -0
- package/src/lib/floor-footprints.test.ts +563 -0
- package/src/lib/floor-footprints.ts +395 -0
- package/src/lib/floor-foundation-support.test.ts +283 -0
- package/src/lib/floor-opening-draft.ts +218 -0
- package/src/lib/floor-openings.test.ts +216 -0
- package/src/lib/floor-region-geometry.ts +191 -0
- package/src/lib/floor-region-session.test.ts +485 -0
- package/src/lib/floor-region-session.ts +589 -0
- package/src/lib/floor-region-snap.ts +101 -0
- package/src/lib/floor-step-hover.test.ts +32 -0
- package/src/lib/floorplan/annotation-visibility.ts +17 -0
- package/src/lib/floorplan/dimension-drive.test.ts +360 -0
- package/src/lib/floorplan/dimension-drive.ts +479 -0
- package/src/lib/floorplan/floorplan-export.test.ts +12 -8
- package/src/lib/floorplan/floorplan-export.tsx +32 -8
- package/src/lib/floorplan/floorplan-extension.ts +32 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
- package/src/lib/floorplan/floorplan-mode.ts +2 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
- package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
- package/src/lib/floorplan/floorplan-readonly.ts +6 -0
- package/src/lib/floorplan/geometry.ts +13 -6
- package/src/lib/floorplan/index.ts +0 -9
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/marks.test.ts +213 -0
- package/src/lib/floorplan/marks.ts +238 -0
- package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
- package/src/lib/floorplan/open-wall-ends.ts +162 -0
- package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
- package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
- package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
- package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
- package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
- package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
- package/src/lib/floorplan/schedules.test.ts +189 -0
- package/src/lib/floorplan/schedules.ts +339 -0
- package/src/lib/floorplan/sheet-export.test.ts +100 -0
- package/src/lib/floorplan/sheet-export.ts +318 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
- package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
- package/src/lib/floorplan/site-plan/contributors.ts +97 -0
- package/src/lib/floorplan/site-plan/coverage.ts +232 -0
- package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
- package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
- package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
- package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
- package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
- package/src/lib/floorplan/site-plan/geometry.ts +557 -0
- package/src/lib/floorplan/site-plan/index.ts +71 -0
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
- package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
- package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
- package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
- package/src/lib/floorplan/site-plan/street-name.ts +302 -0
- package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
- package/src/lib/floorplan/types.ts +1 -27
- package/src/lib/fresh-planar-placement.test.ts +36 -0
- package/src/lib/fresh-planar-placement.ts +59 -36
- package/src/lib/geometry-script/author.ts +152 -0
- package/src/lib/geometry-script/client.ts +152 -0
- package/src/lib/geometry-script/protocol.ts +12 -0
- package/src/lib/gesture-lifecycle.test.ts +595 -0
- package/src/lib/gesture-lifecycle.ts +290 -0
- package/src/lib/glb-export-zone-holes.test.ts +41 -0
- package/src/lib/glb-export.test.ts +485 -0
- package/src/lib/glb-export.ts +237 -119
- package/src/lib/history.test.ts +6 -0
- package/src/lib/history.ts +7 -0
- package/src/lib/hud-title.ts +109 -0
- package/src/lib/hydration-gated-storage.ts +49 -0
- package/src/lib/ifc-export.test.ts +174 -0
- package/src/lib/ifc-export.ts +201 -0
- package/src/lib/ifc-parts.ts +15 -0
- package/src/lib/inspector-expanded.test.ts +44 -0
- package/src/lib/inspector-expanded.ts +36 -0
- package/src/lib/interaction/scope.ts +18 -0
- package/src/lib/keyboard-pan.test.ts +129 -7
- package/src/lib/keyboard-pan.ts +161 -12
- package/src/lib/live-gesture-round-trip.test.ts +360 -0
- package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
- package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
- package/src/lib/lot/clean-ring.test.ts +186 -0
- package/src/lib/lot/clean-ring.ts +217 -0
- package/src/lib/lot/dossier.test.ts +202 -0
- package/src/lib/lot/dossier.ts +498 -0
- package/src/lib/lot/drop-in.test.ts +326 -0
- package/src/lib/lot/drop-in.ts +407 -0
- package/src/lib/lot/index.ts +43 -0
- package/src/lib/lot/lot-patch.test.ts +340 -0
- package/src/lib/lot/lot-patch.ts +394 -0
- package/src/lib/lot/parcel-provider.ts +43 -0
- package/src/lib/lot/terrain.test.ts +161 -0
- package/src/lib/lot/terrain.ts +266 -0
- package/src/lib/material-catalog-model.ts +3 -1
- package/src/lib/material-paint-panel-model.ts +9 -5
- package/src/lib/material-paint.ts +4 -0
- package/src/lib/mezzanine-actions.test.ts +342 -0
- package/src/lib/mezzanine-draft.test.ts +501 -0
- package/src/lib/mezzanine-draft.ts +278 -0
- package/src/lib/mezzanine-messages.ts +26 -0
- package/src/lib/mezzanine-selection.test.ts +207 -0
- package/src/lib/mezzanine-stairs.ts +46 -0
- package/src/lib/model-export.ts +3 -1
- package/src/lib/node-action-movement.ts +17 -6
- package/src/lib/node-display-label.ts +37 -0
- package/src/lib/orbit-floor-clearance.test.ts +165 -0
- package/src/lib/orbit-floor-clearance.ts +106 -0
- package/src/lib/paint-commit-route.test.ts +37 -0
- package/src/lib/paint-commit-route.ts +17 -0
- package/src/lib/paint-eyedropper.test.tsx +165 -0
- package/src/lib/paint-eyedropper.ts +99 -0
- package/src/lib/paint-pass-through.test.ts +111 -0
- package/src/lib/paint-pass-through.ts +76 -0
- package/src/lib/paint-region-hover.ts +25 -0
- package/src/lib/paint-region-mode.ts +154 -0
- package/src/lib/paint-regions.test.ts +166 -0
- package/src/lib/paint-regions.ts +192 -0
- package/src/lib/paint-scope.test.ts +317 -35
- package/src/lib/paint-scope.ts +266 -25
- package/src/lib/panel-rows.ts +21 -0
- package/src/lib/parametric-node-update.ts +1 -1
- package/src/lib/plate-paint-affected.test.ts +300 -0
- package/src/lib/plate-paint-affected.ts +177 -0
- package/src/lib/plugin-chat.ts +50 -0
- package/src/lib/plugin-panels.test.ts +25 -0
- package/src/lib/plugin-panels.ts +94 -0
- package/src/lib/print-shell-compiler.ts +1 -0
- package/src/lib/registered-draft-snap.ts +12 -0
- package/src/lib/roof-duplication.ts +10 -2
- package/src/lib/room-assembly-overlay.test.ts +275 -0
- package/src/lib/room-assembly-overlay.ts +266 -0
- package/src/lib/room-built-on.test.ts +379 -0
- package/src/lib/room-built-on.ts +138 -0
- package/src/lib/room-construction-commands.ts +712 -0
- package/src/lib/room-construction.test.ts +746 -0
- package/src/lib/room-construction.ts +385 -0
- package/src/lib/room-controls-phase6.test.ts +630 -0
- package/src/lib/room-divide-session.ts +523 -0
- package/src/lib/room-handle-drag.ts +1068 -0
- package/src/lib/room-name-catalog.test.ts +90 -0
- package/src/lib/room-name-catalog.ts +168 -0
- package/src/lib/room-push-dimensions.test.ts +311 -0
- package/src/lib/room-push-dimensions.ts +251 -0
- package/src/lib/room-selection-commands.ts +62 -0
- package/src/lib/room-selection.test.ts +973 -0
- package/src/lib/room-selection.ts +417 -0
- package/src/lib/room-structure-commands.ts +94 -0
- package/src/lib/room-structure.test.ts +921 -0
- package/src/lib/room-transform-session.ts +570 -0
- package/src/lib/room-zone-routing.test.ts +294 -0
- package/src/lib/room-zone-routing.ts +197 -0
- package/src/lib/scene-clipboard.test.ts +364 -0
- package/src/lib/scene-clipboard.ts +229 -48
- package/src/lib/scene-material-refs.test.ts +79 -0
- package/src/lib/scene-material-refs.ts +79 -0
- package/src/lib/scene.test.ts +24 -36
- package/src/lib/scene.ts +33 -57
- package/src/lib/selection-routing.test.ts +129 -45
- package/src/lib/selection-routing.ts +53 -74
- package/src/lib/session-writes.test.ts +120 -0
- package/src/lib/session-writes.ts +205 -0
- package/src/lib/sidebar-panel.ts +62 -0
- package/src/lib/site-boundary.test.ts +4 -6
- package/src/lib/site-boundary.ts +1 -1
- package/src/lib/snapping-mode.test.ts +4 -0
- package/src/lib/snapping-mode.ts +8 -1
- package/src/lib/terrace-draft.test.ts +145 -0
- package/src/lib/terrace-draft.ts +213 -0
- package/src/lib/tool-cursor-icon.test.ts +16 -0
- package/src/lib/tool-cursor-icon.ts +11 -0
- package/src/lib/typing-target.ts +9 -0
- package/src/lib/units.ts +77 -39
- package/src/lib/vector-edges.test.ts +58 -0
- package/src/lib/vector-edges.ts +400 -0
- package/src/lib/viewer-selection.test.ts +577 -0
- package/src/lib/viewer-selection.ts +569 -0
- package/src/lib/wall-draw-variant.test.ts +71 -0
- package/src/lib/wall-draw-variant.ts +91 -0
- package/src/lib/wall-paint-region-session.ts +246 -0
- package/src/lib/wall-paint-region.test.ts +373 -0
- package/src/lib/wall-polygon-draft.test.ts +262 -0
- package/src/lib/wall-polygon-draft.ts +286 -0
- package/src/lib/wall-push.test.ts +453 -0
- package/src/lib/wall-region-face.ts +352 -0
- package/src/lib/wall-region-handles.test.ts +304 -0
- package/src/lib/wall-region-handles.ts +368 -0
- package/src/lib/wall-region-list.test.ts +119 -0
- package/src/lib/wall-region-list.ts +136 -0
- package/src/lib/wall-region-snap.ts +110 -0
- package/src/lib/window-interaction.ts +14 -0
- package/src/lib/zone-content.ts +4 -1
- package/src/lib/zone-name.test.ts +17 -0
- package/src/lib/zone-name.ts +17 -0
- package/src/store/opens-in-select-mode.test.ts +223 -0
- package/src/store/paint-mode.test.ts +188 -0
- package/src/store/terrain-sculpt-mode.test.ts +19 -21
- package/src/store/tool-mode.test.ts +137 -6
- package/src/store/use-delete-confirmation.ts +28 -1
- package/src/store/use-drawing-view.test.ts +24 -3
- package/src/store/use-drawing-view.ts +29 -1
- package/src/store/use-editor.tsx +432 -321
- package/src/store/use-floorplan-draft-preview.ts +13 -0
- package/src/store/use-floorplan-marquee.ts +12 -2
- package/src/store/use-quick-measurement-hud.ts +3 -3
- package/src/store/use-stair-build-preview.ts +13 -5
- package/src/store/use-wall-move-ghosts.ts +4 -2
- package/src/store/use-wall-region-handles-selection.test.ts +222 -0
- package/src/store/use-wall-region-handles-selection.ts +139 -0
- package/src/store/view-layout.test.ts +175 -0
- package/src/store/view-layout.ts +196 -0
- package/src/test-utils/immediate-animation-frames.ts +16 -0
- package/src/test-utils/live-gesture-round-trip.ts +224 -0
- package/src/test-utils/selection-harness.ts +82 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
- package/src/components/tools/fence/fence-drafting.test.ts +0 -100
- package/src/components/tools/fence/fence-drafting.ts +0 -266
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
- package/src/components/viewer/viewer-units-panel.tsx +0 -116
- package/src/lib/floorplan/stairs.ts +0 -489
- package/src/lib/level-duplication.test.ts +0 -144
- package/src/lib/level-duplication.ts +0 -195
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
floorLimitAt,
|
|
4
|
+
glideFloorLimit,
|
|
5
|
+
LOOK_UP_PITCH_GAIN,
|
|
6
|
+
LOOK_UP_PUSH_HALF_ANGLE,
|
|
7
|
+
MAX_ORBIT_POLAR_ANGLE,
|
|
8
|
+
ORBIT_FLOOR_CLEARANCE,
|
|
9
|
+
ORBIT_TARGET_HEIGHT,
|
|
10
|
+
orbitAngleBelowFloor,
|
|
11
|
+
orbitCameraAboveFloor,
|
|
12
|
+
} from './orbit-floor-clearance'
|
|
13
|
+
|
|
14
|
+
type Vec3 = { x: number; y: number; z: number }
|
|
15
|
+
|
|
16
|
+
const FLOOR_Y = 3
|
|
17
|
+
const MIN_Y = FLOOR_Y + ORBIT_FLOOR_CLEARANCE
|
|
18
|
+
const target: Vec3 = { x: 1, y: FLOOR_Y, z: -2 }
|
|
19
|
+
|
|
20
|
+
// camera-controls' orbit: polar 0 looks straight down, PI/2 is level with the target.
|
|
21
|
+
function orbit(distance: number, polar: number, azimuth = 0.4): Vec3 {
|
|
22
|
+
return {
|
|
23
|
+
x: target.x + distance * Math.sin(polar) * Math.sin(azimuth),
|
|
24
|
+
y: target.y + distance * Math.cos(polar),
|
|
25
|
+
z: target.z + distance * Math.sin(polar) * Math.cos(azimuth),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function present(position: Vec3) {
|
|
30
|
+
const out = { x: Number.NaN, y: Number.NaN, z: Number.NaN }
|
|
31
|
+
const polar = orbitCameraAboveFloor(out, position, target, MIN_Y)
|
|
32
|
+
return { grounded: out.y === MIN_Y && position.y < MIN_Y, out, polar }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const horizontalDistance = (p: Vec3) => Math.hypot(p.x - target.x, p.z - target.z)
|
|
36
|
+
const DEGREE = Math.PI / 180
|
|
37
|
+
|
|
38
|
+
test('an orbit above the clearance is drawn exactly where it is', () => {
|
|
39
|
+
const position = orbit(10, Math.PI / 3)
|
|
40
|
+
const { grounded, out, polar } = present(position)
|
|
41
|
+
expect(grounded).toBe(false)
|
|
42
|
+
expect(out).toEqual(position)
|
|
43
|
+
expect(polar).toBeCloseTo(Math.PI / 3, 9)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('tilting past the horizon keeps the camera on the floor clearance', () => {
|
|
47
|
+
for (const distance of [2, 8, 40]) {
|
|
48
|
+
for (const polar of [Math.PI / 2, 0.6 * Math.PI, 0.75 * Math.PI, Math.PI - 0.05]) {
|
|
49
|
+
const position = orbit(distance, polar)
|
|
50
|
+
const { grounded, out } = present(position)
|
|
51
|
+
expect(grounded).toBe(true)
|
|
52
|
+
expect(out.y).toBeCloseTo(MIN_Y, 9)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('past the clearance the view pitches up faster than the drag and stops at straight up', () => {
|
|
58
|
+
for (const distance of [2.2, 8, 40]) {
|
|
59
|
+
const contactPolar = Math.acos(ORBIT_FLOOR_CLEARANCE / distance)
|
|
60
|
+
for (let past = 0; past <= 60 * DEGREE; past += DEGREE) {
|
|
61
|
+
const { polar } = present(orbit(distance, contactPolar + past))
|
|
62
|
+
expect(polar).toBeCloseTo(
|
|
63
|
+
Math.min(contactPolar + past * LOOK_UP_PITCH_GAIN, MAX_ORBIT_POLAR_ANGLE),
|
|
64
|
+
9,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
// From level to (almost) straight up within ~40° of drag.
|
|
68
|
+
const straightUp = (MAX_ORBIT_POLAR_ANGLE - contactPolar) / LOOK_UP_PITCH_GAIN
|
|
69
|
+
expect(straightUp).toBeLessThan(95 * DEGREE)
|
|
70
|
+
// And stops there: more drag changes nothing.
|
|
71
|
+
const atTop = present(orbit(distance, contactPolar + straightUp))
|
|
72
|
+
const beyond = present(orbit(distance, contactPolar + straightUp + 5 * DEGREE))
|
|
73
|
+
expect(beyond.polar).toBeCloseTo(MAX_ORBIT_POLAR_ANGLE, 9)
|
|
74
|
+
expect(horizontalDistance(beyond.out)).toBeCloseTo(horizontalDistance(atTop.out), 9)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('past the clearance the camera closes on its target gently, at the half-angle rate', () => {
|
|
79
|
+
for (const distance of [2.2, 8, 40]) {
|
|
80
|
+
const contactPolar = Math.acos(ORBIT_FLOOR_CLEARANCE / distance)
|
|
81
|
+
const contactReach = horizontalDistance(present(orbit(distance, contactPolar)).out)
|
|
82
|
+
let previous = contactReach
|
|
83
|
+
const straightUp = (MAX_ORBIT_POLAR_ANGLE - contactPolar) / LOOK_UP_PITCH_GAIN
|
|
84
|
+
for (let polar = contactPolar + 0.01; polar <= contactPolar + straightUp; polar += 0.01) {
|
|
85
|
+
const position = orbit(distance, polar)
|
|
86
|
+
expect(orbitAngleBelowFloor(position, target, MIN_Y)).toBeCloseTo(polar - contactPolar, 9)
|
|
87
|
+
const reach = horizontalDistance(present(position).out)
|
|
88
|
+
expect(reach).toBeCloseTo(
|
|
89
|
+
contactReach * 0.5 ** ((polar - contactPolar) / LOOK_UP_PUSH_HALF_ANGLE),
|
|
90
|
+
9,
|
|
91
|
+
)
|
|
92
|
+
expect(reach).toBeLessThan(previous)
|
|
93
|
+
previous = reach
|
|
94
|
+
}
|
|
95
|
+
// By the time the view looks straight up it has closed at most ~25% of the way.
|
|
96
|
+
const there = horizontalDistance(present(orbit(distance, contactPolar + straightUp)).out)
|
|
97
|
+
expect(1 - there / contactReach).toBeLessThan(0.75)
|
|
98
|
+
expect(1 - there / contactReach).toBeGreaterThan(0.35)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('crossing the clearance is continuous, so orbiting back up has no jump', () => {
|
|
103
|
+
const distance = 6
|
|
104
|
+
// The polar angle at which the orbit itself touches the clearance.
|
|
105
|
+
const contact = Math.acos(ORBIT_FLOOR_CLEARANCE / distance)
|
|
106
|
+
const before = present(orbit(distance, contact - 1e-6)).out
|
|
107
|
+
const after = present(orbit(distance, contact + 1e-6)).out
|
|
108
|
+
expect(Math.hypot(after.x - before.x, after.y - before.y, after.z - before.z)).toBeLessThan(1e-4)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('zooming in while grounded still moves the camera toward the target', () => {
|
|
112
|
+
const polar = 0.8 * Math.PI
|
|
113
|
+
let previous = Number.POSITIVE_INFINITY
|
|
114
|
+
for (let distance = 40; distance >= 2; distance -= 2) {
|
|
115
|
+
const reach = horizontalDistance(present(orbit(distance, polar)).out)
|
|
116
|
+
expect(reach).toBeLessThan(previous)
|
|
117
|
+
previous = reach
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('with the pivot at standing height, a close orbit looks around before the floor stops it', () => {
|
|
122
|
+
const pivot = { x: 0, y: FLOOR_Y + ORBIT_TARGET_HEIGHT, z: 0 }
|
|
123
|
+
const distance = 2
|
|
124
|
+
const at = (polar: number) => ({
|
|
125
|
+
x: distance * Math.sin(polar),
|
|
126
|
+
y: pivot.y + distance * Math.cos(polar),
|
|
127
|
+
z: 0,
|
|
128
|
+
})
|
|
129
|
+
// Level with the pivot and a little past it: a free orbit at about eye
|
|
130
|
+
// height, already looking up, nothing pushed.
|
|
131
|
+
for (const polar of [Math.PI / 2, 100 * DEGREE, 105 * DEGREE]) {
|
|
132
|
+
const out = { x: 0, y: 0, z: 0 }
|
|
133
|
+
expect(orbitCameraAboveFloor(out, at(polar), pivot, MIN_Y)).toBeCloseTo(polar, 9)
|
|
134
|
+
expect(out).toEqual(at(polar))
|
|
135
|
+
}
|
|
136
|
+
// The clearance takes over only where the orbit would reach it.
|
|
137
|
+
const contact = Math.acos((MIN_Y - pivot.y) / distance)
|
|
138
|
+
expect(contact).toBeGreaterThan(105 * DEGREE)
|
|
139
|
+
const out = { x: 0, y: 0, z: 0 }
|
|
140
|
+
orbitCameraAboveFloor(out, at(contact + 5 * DEGREE), pivot, MIN_Y)
|
|
141
|
+
expect(out.y).toBeCloseTo(MIN_Y, 9)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('the floor limit sits the clearance above the surface under the camera, or the level floor', () => {
|
|
145
|
+
// A raised platform 0.45 m above a level at 2.71.
|
|
146
|
+
expect(floorLimitAt(3.16, 2.71)).toBeCloseTo(3.16 + ORBIT_FLOOR_CLEARANCE, 9)
|
|
147
|
+
// A sunken floor below the level's own floor.
|
|
148
|
+
expect(floorLimitAt(2.21, 2.71)).toBeCloseTo(2.21 + ORBIT_FLOOR_CLEARANCE, 9)
|
|
149
|
+
// Nothing under the camera (outside, no terrain): the level floor, never 0.
|
|
150
|
+
expect(floorLimitAt(null, 2.71)).toBeCloseTo(2.71 + ORBIT_FLOOR_CLEARANCE, 9)
|
|
151
|
+
expect(floorLimitAt(null, 0)).toBe(ORBIT_FLOOR_CLEARANCE)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('a change in floor height is glided over, never popped', () => {
|
|
155
|
+
expect(glideFloorLimit(null, 1, 1 / 60)).toBe(1)
|
|
156
|
+
let limit = 0.3
|
|
157
|
+
let previous = limit
|
|
158
|
+
for (let frame = 0; frame < 120; frame++) {
|
|
159
|
+
limit = glideFloorLimit(limit, 0.75, 1 / 60)
|
|
160
|
+
expect(limit).toBeGreaterThan(previous)
|
|
161
|
+
expect(limit - previous).toBeLessThan(0.07)
|
|
162
|
+
previous = limit
|
|
163
|
+
}
|
|
164
|
+
expect(limit).toBeCloseTo(0.75, 6)
|
|
165
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
type Vec3 = { x: number; y: number; z: number }
|
|
2
|
+
|
|
3
|
+
/** The lowest an orbiting camera sits above the active level's floor. */
|
|
4
|
+
export const ORBIT_FLOOR_CLEARANCE = 0.45
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How high above the active level's floor the orbit's pivot rests when the
|
|
8
|
+
* editor aims it at the ground (level switch, panning, framing, focusing a
|
|
9
|
+
* floor or room): about standing eye height, so zoomed all the way in the
|
|
10
|
+
* camera looks around the room the way a walkthrough does.
|
|
11
|
+
*/
|
|
12
|
+
export const ORBIT_TARGET_HEIGHT = 1.1
|
|
13
|
+
|
|
14
|
+
const DEGREE = Math.PI / 180
|
|
15
|
+
|
|
16
|
+
/** The steepest an orbit camera looks: straight down is 0, straight up π. */
|
|
17
|
+
export const MAX_ORBIT_POLAR_ANGLE = Math.PI - 0.05
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Past the point where the camera reached the floor clearance, the view
|
|
21
|
+
* pitches up this many times faster than the orbit drag: about 80° more drag
|
|
22
|
+
* takes it from level to straight up.
|
|
23
|
+
*/
|
|
24
|
+
export const LOOK_UP_PITCH_GAIN = 1.1
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The look-up push: every this many degrees of drag past the clearance, the
|
|
28
|
+
* camera's horizontal distance to its target halves. Over the ~80° it takes
|
|
29
|
+
* to look straight up, the camera closes about two thirds of the way in.
|
|
30
|
+
*/
|
|
31
|
+
export const LOOK_UP_PUSH_HALF_ANGLE = 55 * DEGREE
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How far the orbit has tilted past the point where its camera reaches
|
|
35
|
+
* `minY` (radians, negative while it is still above). Zero exactly when the
|
|
36
|
+
* orbit camera sits at `minY`.
|
|
37
|
+
*/
|
|
38
|
+
export function orbitAngleBelowFloor(position: Vec3, target: Vec3, minY: number): number {
|
|
39
|
+
const horizontal = Math.hypot(position.x - target.x, position.z - target.z)
|
|
40
|
+
const height = position.y - target.y
|
|
41
|
+
const distance = Math.hypot(horizontal, height)
|
|
42
|
+
if (distance < 1e-9) return 0
|
|
43
|
+
const polar = Math.atan2(horizontal, height)
|
|
44
|
+
const contactPolar = Math.acos(Math.max(-1, Math.min(1, (minY - target.y) / distance)))
|
|
45
|
+
return polar - contactPolar
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Where to draw an orbit camera so it never drops below `minY`. Above it the
|
|
50
|
+
* orbit pose stands. An orbit that would take it lower keeps the camera on
|
|
51
|
+
* `minY`, pitches the view up `LOOK_UP_PITCH_GAIN` times faster than the
|
|
52
|
+
* drag (stopping at straight up), and pushes the camera gently toward its
|
|
53
|
+
* target at the `LOOK_UP_PUSH_HALF_ANGLE` rate. A pure function of the orbit
|
|
54
|
+
* state, so orbiting back up returns the exact orbit pose.
|
|
55
|
+
*
|
|
56
|
+
* Writes the drawn position into `out` and returns the drawn polar angle.
|
|
57
|
+
*/
|
|
58
|
+
export function orbitCameraAboveFloor(
|
|
59
|
+
out: Vec3,
|
|
60
|
+
position: Vec3,
|
|
61
|
+
target: Vec3,
|
|
62
|
+
minY: number,
|
|
63
|
+
): number {
|
|
64
|
+
const dx = position.x - target.x
|
|
65
|
+
const dz = position.z - target.z
|
|
66
|
+
const horizontal = Math.hypot(dx, dz)
|
|
67
|
+
const height = position.y - target.y
|
|
68
|
+
if (!(position.y < minY)) {
|
|
69
|
+
out.x = position.x
|
|
70
|
+
out.y = position.y
|
|
71
|
+
out.z = position.z
|
|
72
|
+
return Math.atan2(horizontal, height)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const rise = minY - target.y
|
|
76
|
+
const distance = Math.hypot(horizontal, height)
|
|
77
|
+
const contactRadius = Math.sqrt(Math.max(0, distance * distance - rise * rise))
|
|
78
|
+
const below = Math.max(0, orbitAngleBelowFloor(position, target, minY))
|
|
79
|
+
const contactPolar = Math.atan2(horizontal, height) - below
|
|
80
|
+
// Nothing moves once the view looks straight up.
|
|
81
|
+
const pastContact = Math.min(below, (MAX_ORBIT_POLAR_ANGLE - contactPolar) / LOOK_UP_PITCH_GAIN)
|
|
82
|
+
const reach = contactRadius * 0.5 ** (pastContact / LOOK_UP_PUSH_HALF_ANGLE)
|
|
83
|
+
const scale = horizontal > 1e-9 ? reach / horizontal : 0
|
|
84
|
+
out.x = target.x + dx * scale
|
|
85
|
+
out.y = minY
|
|
86
|
+
out.z = target.z + dz * scale
|
|
87
|
+
return contactPolar + pastContact * LOOK_UP_PITCH_GAIN
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// How quickly the floor limit follows a change in the floor under the camera
|
|
91
|
+
// (stepping over a raised platform's edge): about this many seconds per e-fold.
|
|
92
|
+
const FLOOR_GLIDE_SECONDS = 0.12
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The lowest the camera may sit: the clearance above the floor surface under
|
|
96
|
+
* it, or above the level's own floor when there is none (outside, no terrain).
|
|
97
|
+
*/
|
|
98
|
+
export function floorLimitAt(surfaceTop: number | null, levelFloorY: number): number {
|
|
99
|
+
return (surfaceTop ?? levelFloorY) + ORBIT_FLOOR_CLEARANCE
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Eases a floor limit toward `target` over `delta` seconds, so it never pops. */
|
|
103
|
+
export function glideFloorLimit(current: number | null, target: number, delta: number): number {
|
|
104
|
+
if (current === null || !(delta > 0)) return current ?? target
|
|
105
|
+
return target + (current - target) * Math.exp(-delta / FLOOR_GLIDE_SECONDS)
|
|
106
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { paintCommitRoute } from './paint-commit-route'
|
|
3
|
+
|
|
4
|
+
test('only the hovered surface itself keeps the kind commit', () => {
|
|
5
|
+
expect(paintCommitRoute([], 'wall_a', 'a')).toBe('own')
|
|
6
|
+
expect(paintCommitRoute([{ nodeId: 'wall_a', role: 'a' }], 'wall_a', 'a')).toBe('own')
|
|
7
|
+
// A one-wall room: one target, but on the room role.
|
|
8
|
+
expect(paintCommitRoute([{ nodeId: 'wall_a', role: 'room:zone_1' }], 'wall_a', 'a')).toBe(
|
|
9
|
+
'fanout',
|
|
10
|
+
)
|
|
11
|
+
expect(paintCommitRoute([{ nodeId: 'wall_b', role: 'a' }], 'wall_a', 'a')).toBe('fanout')
|
|
12
|
+
expect(
|
|
13
|
+
paintCommitRoute(
|
|
14
|
+
[
|
|
15
|
+
{ nodeId: 'wall_a', role: 'a' },
|
|
16
|
+
{ nodeId: 'wall_b', role: 'a' },
|
|
17
|
+
],
|
|
18
|
+
'wall_a',
|
|
19
|
+
'a',
|
|
20
|
+
),
|
|
21
|
+
).toBe('fanout')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
for (const role of ['step:zone_upper', 'edge:zone_upper'])
|
|
25
|
+
test(`${role} retains the room side role through paint routing`, () => {
|
|
26
|
+
expect(paintCommitRoute([{ nodeId: 'slab_platform', role }], 'slab_platform', role)).toBe('own')
|
|
27
|
+
expect(
|
|
28
|
+
paintCommitRoute(
|
|
29
|
+
[
|
|
30
|
+
{ nodeId: 'slab_platform', role },
|
|
31
|
+
{ nodeId: 'slab_component', role },
|
|
32
|
+
],
|
|
33
|
+
'slab_platform',
|
|
34
|
+
role,
|
|
35
|
+
),
|
|
36
|
+
).toBe('fanout')
|
|
37
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a paint click commits its resolved spread. Only a spread that is exactly
|
|
3
|
+
* the hovered surface keeps the kind's own commit; anything else — several
|
|
4
|
+
* targets, or one target on another role such as a whole room's
|
|
5
|
+
* `room:<zoneId>` role — goes through the fan-out, which routes each role to
|
|
6
|
+
* the kind that owns it. A one-wall room therefore needs no second target to
|
|
7
|
+
* reach its room role.
|
|
8
|
+
*/
|
|
9
|
+
export function paintCommitRoute(
|
|
10
|
+
targets: ReadonlyArray<{ nodeId: string; role: string }>,
|
|
11
|
+
nodeId: string,
|
|
12
|
+
role: string,
|
|
13
|
+
): 'own' | 'fanout' {
|
|
14
|
+
if (targets.length === 0) return 'own'
|
|
15
|
+
const [only] = targets
|
|
16
|
+
return targets.length === 1 && only!.nodeId === nodeId && only!.role === role ? 'own' : 'fanout'
|
|
17
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
nodeRegistry,
|
|
5
|
+
registerNode,
|
|
6
|
+
SlabNode,
|
|
7
|
+
useScene,
|
|
8
|
+
WallNode,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useNodeEvents, useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { _roots, act, events } from '@react-three/fiber'
|
|
12
|
+
import type { OrthographicCamera } from 'three'
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
import { SelectionManager, useEditor } from '../index'
|
|
15
|
+
import { withSelectionHarness } from '../test-utils/selection-harness'
|
|
16
|
+
import { resetPaintMode, usePaintRegionMode } from './paint-region-mode'
|
|
17
|
+
|
|
18
|
+
const picked = { materialPreset: 'library:wall-red', sourceTarget: 'wall' } as const
|
|
19
|
+
|
|
20
|
+
function Surface({ node }: { node: AnyNode }) {
|
|
21
|
+
const wall = node.type === 'wall'
|
|
22
|
+
return (
|
|
23
|
+
<mesh
|
|
24
|
+
{...useNodeEvents(node, node.type)}
|
|
25
|
+
name={node.id}
|
|
26
|
+
position={wall ? [0, 1.5, 0] : [0, 0, 0]}
|
|
27
|
+
rotation={wall ? [0, 0, 0] : [-Math.PI / 2, 0, 0]}
|
|
28
|
+
>
|
|
29
|
+
<planeGeometry args={wall ? [20, 3] : [20, 20]} />
|
|
30
|
+
<meshBasicMaterial />
|
|
31
|
+
</mesh>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const activation of ['icon', 'Alt'] as const) {
|
|
36
|
+
test(`${activation} eyedrop consumes the front wall click; only the next click paints the slab behind`, async () => {
|
|
37
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
38
|
+
const previousPaint = usePaintRegionMode.getState()
|
|
39
|
+
const previousDocument = globalThis.document
|
|
40
|
+
const previousCancel = globalThis.cancelAnimationFrame
|
|
41
|
+
globalThis.cancelAnimationFrame = () => {}
|
|
42
|
+
globalThis.document = { body: { style: { cursor: '' } } } as unknown as Document
|
|
43
|
+
nodeRegistry._reset()
|
|
44
|
+
const writes: string[] = []
|
|
45
|
+
try {
|
|
46
|
+
for (const kind of ['wall', 'slab']) {
|
|
47
|
+
registerNode({
|
|
48
|
+
kind,
|
|
49
|
+
schemaVersion: 1,
|
|
50
|
+
schema: z.object({ type: z.literal(kind) }) as never,
|
|
51
|
+
category: 'structure',
|
|
52
|
+
defaults: () => ({}),
|
|
53
|
+
capabilities: {
|
|
54
|
+
slots: () => [{ slotId: 'default', label: 'Surface' }],
|
|
55
|
+
paint: {
|
|
56
|
+
resolveRole: () => 'default',
|
|
57
|
+
getEffectiveMaterial: () => ({
|
|
58
|
+
material: undefined,
|
|
59
|
+
materialPreset: picked.materialPreset,
|
|
60
|
+
}),
|
|
61
|
+
buildPatch: ({ materialPreset }) => ({ materialPreset }) as Partial<AnyNode>,
|
|
62
|
+
commit: ({ node, materialPreset }) => {
|
|
63
|
+
writes.push(node.id)
|
|
64
|
+
useScene.getState().updateNode(node.id, { materialPreset } as Partial<AnyNode>)
|
|
65
|
+
},
|
|
66
|
+
applyPreview: () => () => {},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
await withSelectionHarness(async ({ canvas, render }) => {
|
|
72
|
+
const wall = WallNode.parse({ id: 'wall_front', start: [-10, 0], end: [10, 0] })
|
|
73
|
+
const slab = SlabNode.parse({
|
|
74
|
+
id: 'slab_behind',
|
|
75
|
+
polygon: [
|
|
76
|
+
[-10, -10],
|
|
77
|
+
[10, -10],
|
|
78
|
+
[10, 10],
|
|
79
|
+
[-10, 10],
|
|
80
|
+
],
|
|
81
|
+
})
|
|
82
|
+
useScene.setState({ nodes: { [wall.id]: wall, [slab.id]: slab }, readOnly: false })
|
|
83
|
+
useViewer.getState().resetSelection()
|
|
84
|
+
useViewer.setState({ cameraDragging: false, inputDragging: false })
|
|
85
|
+
resetPaintMode()
|
|
86
|
+
useEditor.getState().armMaterialPaint(undefined, activation === 'icon' ? 'pick' : 'surface')
|
|
87
|
+
await render(
|
|
88
|
+
<>
|
|
89
|
+
<SelectionManager />
|
|
90
|
+
<Surface node={wall} />
|
|
91
|
+
<Surface node={slab} />
|
|
92
|
+
</>,
|
|
93
|
+
)
|
|
94
|
+
if (activation === 'Alt') {
|
|
95
|
+
await act(async () => {
|
|
96
|
+
window.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Alt', repeat: false }))
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
expect(usePaintRegionMode.getState().mode).toBe('pick')
|
|
100
|
+
const store = _roots.get(canvas)!.store
|
|
101
|
+
const state = store.getState()
|
|
102
|
+
const camera = state.camera as OrthographicCamera
|
|
103
|
+
Object.assign(camera, { left: -5, right: 5, top: 5, bottom: -5 })
|
|
104
|
+
camera.position.set(0, 5, 10)
|
|
105
|
+
camera.up.set(0, 1, 0)
|
|
106
|
+
camera.lookAt(0, 1, 0)
|
|
107
|
+
camera.updateProjectionMatrix()
|
|
108
|
+
camera.updateMatrixWorld(true)
|
|
109
|
+
state.setEvents(events(store))
|
|
110
|
+
state.scene.updateMatrixWorld(true)
|
|
111
|
+
const send = async (type: 'onPointerMove' | 'onPointerDown' | 'onPointerUp') => {
|
|
112
|
+
await act(async () => {
|
|
113
|
+
store.getState().events.handlers
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
// Hover first: sampling synchronously replays this event when it returns to paint.
|
|
126
|
+
await send('onPointerMove')
|
|
127
|
+
expect(useViewer.getState().hoveredId).toBe(wall.id)
|
|
128
|
+
const hits = state.raycaster.intersectObjects(state.internal.interaction, true)
|
|
129
|
+
expect([...new Set(hits.map((hit) => hit.object.name))]).toEqual([wall.id, slab.id])
|
|
130
|
+
const before = useScene.getState().nodes
|
|
131
|
+
await send('onPointerDown')
|
|
132
|
+
await send('onPointerUp')
|
|
133
|
+
expect(useEditor.getState().activePaintMaterial).toEqual(picked)
|
|
134
|
+
expect(usePaintRegionMode.getState().mode).toBe('surface')
|
|
135
|
+
expect(writes).toEqual([])
|
|
136
|
+
expect(useScene.getState().nodes).toBe(before)
|
|
137
|
+
if (activation === 'Alt') {
|
|
138
|
+
await act(async () =>
|
|
139
|
+
window.dispatchEvent(Object.assign(new Event('keyup'), { key: 'Alt' })),
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
// Expose the slab at the same cursor, then prove the next gesture paints once.
|
|
143
|
+
await render(
|
|
144
|
+
<>
|
|
145
|
+
<SelectionManager />
|
|
146
|
+
<Surface node={slab} />
|
|
147
|
+
</>,
|
|
148
|
+
)
|
|
149
|
+
state.scene.updateMatrixWorld(true)
|
|
150
|
+
await send('onPointerMove')
|
|
151
|
+
await send('onPointerDown')
|
|
152
|
+
await send('onPointerUp')
|
|
153
|
+
expect(writes).toEqual([slab.id])
|
|
154
|
+
expect((useScene.getState().nodes[slab.id] as SlabNode).materialPreset).toBe(
|
|
155
|
+
picked.materialPreset,
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
} finally {
|
|
159
|
+
restoreRegistry()
|
|
160
|
+
usePaintRegionMode.setState(previousPaint)
|
|
161
|
+
globalThis.document = previousDocument
|
|
162
|
+
globalThis.cancelAnimationFrame = previousCancel
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type MaterialSchema,
|
|
5
|
+
nodeRegistry,
|
|
6
|
+
type SceneMaterial,
|
|
7
|
+
type SceneMaterialId,
|
|
8
|
+
slotDefaultPaintMaterial,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import type { Material, Object3D } from 'three'
|
|
11
|
+
import {
|
|
12
|
+
type ActivePaintMaterial,
|
|
13
|
+
hasActivePaintMaterial,
|
|
14
|
+
type PaintableMaterialTarget,
|
|
15
|
+
resolveActivePaintMaterialFromSelection,
|
|
16
|
+
} from './material-paint'
|
|
17
|
+
|
|
18
|
+
type RenderedMaterial = Material & {
|
|
19
|
+
color?: { getHexString: () => string }
|
|
20
|
+
roughness?: number
|
|
21
|
+
metalness?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The drawn material of a mesh hit, for a slot that carries no finish of its own (an item's GLB). */
|
|
25
|
+
function drawnMaterial(hitObject: Object3D | undefined, materialIndex: number | null) {
|
|
26
|
+
const material = (hitObject as { material?: Material | Material[] } | undefined)?.material
|
|
27
|
+
const drawn = (Array.isArray(material) ? material[materialIndex ?? 0] : material) as
|
|
28
|
+
| RenderedMaterial
|
|
29
|
+
| undefined
|
|
30
|
+
if (!drawn?.color) return null
|
|
31
|
+
return {
|
|
32
|
+
preset: 'custom',
|
|
33
|
+
properties: {
|
|
34
|
+
color: `#${drawn.color.getHexString()}`,
|
|
35
|
+
roughness: drawn.roughness ?? 0.5,
|
|
36
|
+
metalness: drawn.metalness ?? 0,
|
|
37
|
+
opacity: drawn.opacity ?? 1,
|
|
38
|
+
transparent: drawn.transparent ?? false,
|
|
39
|
+
side: 'front',
|
|
40
|
+
},
|
|
41
|
+
} satisfies MaterialSchema
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The material the eyedropper takes from `role` of `node`: what the surface
|
|
46
|
+
* shows, read through the paint path — the kind's `getEffectiveMaterial` (the
|
|
47
|
+
* finish a room, region or override gives it, with derived roles falling to
|
|
48
|
+
* what they draw), the legacy per-kind arms, then the slot's declared default
|
|
49
|
+
* (`node.slots[slot]` → `default`, the renderer's own chain). A slot with none
|
|
50
|
+
* of those (an item's GLB material) is read off the drawn mesh.
|
|
51
|
+
*/
|
|
52
|
+
export function eyedropperMaterial(args: {
|
|
53
|
+
node: AnyNode
|
|
54
|
+
role: string
|
|
55
|
+
nodes: Record<string, AnyNode>
|
|
56
|
+
materials?: Record<SceneMaterialId, SceneMaterial>
|
|
57
|
+
hitObject?: Object3D
|
|
58
|
+
materialIndex?: number | null
|
|
59
|
+
}): ActivePaintMaterial | null {
|
|
60
|
+
const { node, role, nodes } = args
|
|
61
|
+
const definition = nodeRegistry.get(node.type)
|
|
62
|
+
const paint = definition?.capabilities?.paint
|
|
63
|
+
const sourceTarget = (paint?.materialTarget ?? node.type) as PaintableMaterialTarget
|
|
64
|
+
const valid = (value: { material?: MaterialSchema; materialPreset?: string } | null) =>
|
|
65
|
+
value && hasActivePaintMaterial({ ...value, sourceTarget })
|
|
66
|
+
? { material: value.material, materialPreset: value.materialPreset, sourceTarget }
|
|
67
|
+
: null
|
|
68
|
+
|
|
69
|
+
const effective =
|
|
70
|
+
valid(
|
|
71
|
+
paint?.getEffectiveMaterial?.({
|
|
72
|
+
node,
|
|
73
|
+
role,
|
|
74
|
+
nodes: nodes as Record<AnyNodeId, AnyNode>,
|
|
75
|
+
rendered: true,
|
|
76
|
+
materials: args.materials,
|
|
77
|
+
}) ?? null,
|
|
78
|
+
) ??
|
|
79
|
+
resolveActivePaintMaterialFromSelection({
|
|
80
|
+
nodes,
|
|
81
|
+
selectedId: node.id,
|
|
82
|
+
materials: args.materials,
|
|
83
|
+
selectedMaterialTarget: { nodeId: node.id, role },
|
|
84
|
+
})
|
|
85
|
+
if (effective) return effective
|
|
86
|
+
|
|
87
|
+
const declared = definition?.capabilities?.slots?.(node).find((slot) => slot.slotId === role)
|
|
88
|
+
const fallback = valid(slotDefaultPaintMaterial(declared?.default))
|
|
89
|
+
if (fallback) return fallback
|
|
90
|
+
|
|
91
|
+
const drawn = drawnMaterial(args.hitObject, args.materialIndex ?? null)
|
|
92
|
+
return drawn ? { material: drawn, sourceTarget } : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A stable key for a picked material, so an unchanged pick is not re-announced. */
|
|
96
|
+
export function paintMaterialKey(material: ActivePaintMaterial | null): string {
|
|
97
|
+
if (!material) return ''
|
|
98
|
+
return material.materialPreset ?? JSON.stringify(material.material ?? null)
|
|
99
|
+
}
|