@pascal-app/editor 1.0.0-beta.5 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +78 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -239
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +304 -171
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +56 -17
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +63 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +44 -21
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +103 -10
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +5 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +455 -0
- package/src/lib/portable-export.ts +1042 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +38 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-send-to-app.ts +21 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -1,22 +1,53 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
1
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
3
|
type AnyNode,
|
|
4
|
+
type AnyNodeDefinition,
|
|
4
5
|
type AnyNodeId,
|
|
6
|
+
getWallBaseElevationForNodes,
|
|
7
|
+
ItemNode,
|
|
8
|
+
nodeRegistry,
|
|
9
|
+
registerNode,
|
|
10
|
+
type SlabNode,
|
|
5
11
|
sceneRegistry,
|
|
6
12
|
spatialGridManager,
|
|
7
13
|
useScene,
|
|
8
14
|
} from '@pascal-app/core'
|
|
9
|
-
import { useViewer } from '@pascal-app/viewer'
|
|
10
|
-
import {
|
|
15
|
+
import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
|
|
16
|
+
import {
|
|
17
|
+
BoxGeometry,
|
|
18
|
+
Mesh,
|
|
19
|
+
MeshBasicMaterial,
|
|
20
|
+
OrthographicCamera,
|
|
21
|
+
PerspectiveCamera,
|
|
22
|
+
Vector3,
|
|
23
|
+
} from 'three'
|
|
24
|
+
import { z } from 'zod'
|
|
25
|
+
import useInteractionScope from '../../../store/use-interaction-scope'
|
|
26
|
+
import { createWallOnCurrentLevel } from '../wall/wall-drafting'
|
|
11
27
|
import { resolvePointerSupportSurface } from './pointer-support-cap'
|
|
12
28
|
|
|
13
29
|
const LEVEL_ID = 'level_test' as AnyNodeId
|
|
14
30
|
const WALL_ID = 'wall_test' as AnyNodeId
|
|
31
|
+
const PLATFORM_ID = 'plugin-platform_test' as AnyNodeId
|
|
32
|
+
const PLATFORM_KIND = 'plugin-platform'
|
|
15
33
|
|
|
16
34
|
describe('resolvePointerSupportSurface node tops', () => {
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
if (nodeRegistry.has(PLATFORM_KIND)) return
|
|
37
|
+
registerNode({
|
|
38
|
+
kind: PLATFORM_KIND,
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
schema: z.object({}),
|
|
41
|
+
category: 'structure',
|
|
42
|
+
defaults: () => ({}),
|
|
43
|
+
capabilities: { surfaces: { top: { height: 2 } } },
|
|
44
|
+
} as unknown as AnyNodeDefinition)
|
|
45
|
+
})
|
|
46
|
+
|
|
17
47
|
beforeEach(() => {
|
|
18
48
|
spatialGridManager.clear()
|
|
19
49
|
sceneRegistry.clear()
|
|
50
|
+
useInteractionScope.getState().end()
|
|
20
51
|
useViewer.setState({
|
|
21
52
|
selection: {
|
|
22
53
|
buildingId: null,
|
|
@@ -59,12 +90,63 @@ describe('resolvePointerSupportSurface node tops', () => {
|
|
|
59
90
|
sceneRegistry.clear()
|
|
60
91
|
})
|
|
61
92
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
93
|
+
const addPluginPlatform = (z = 0, size: [number, number, number] = [4, 2, 4]) => {
|
|
94
|
+
useScene.setState((state) => ({
|
|
95
|
+
nodes: {
|
|
96
|
+
...state.nodes,
|
|
97
|
+
[PLATFORM_ID]: {
|
|
98
|
+
id: PLATFORM_ID,
|
|
99
|
+
type: PLATFORM_KIND,
|
|
100
|
+
object: 'node',
|
|
101
|
+
parentId: LEVEL_ID,
|
|
102
|
+
visible: true,
|
|
103
|
+
metadata: {},
|
|
104
|
+
} as unknown as AnyNode,
|
|
105
|
+
},
|
|
106
|
+
}))
|
|
107
|
+
const platformMesh = new Mesh(new BoxGeometry(...size), new MeshBasicMaterial())
|
|
108
|
+
platformMesh.position.set(0, 1, z)
|
|
109
|
+
platformMesh.updateMatrixWorld(true)
|
|
110
|
+
sceneRegistry.nodes.set(PLATFORM_ID, platformMesh)
|
|
111
|
+
sceneRegistry.byType[PLATFORM_KIND]!.add(PLATFORM_ID)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test('keeps an off-center orthographic ray on the cursor line', () => {
|
|
115
|
+
const camera = new OrthographicCamera(-20, 20, 20, -20, -1000, 1000)
|
|
116
|
+
camera.position.set(10, 10, 10)
|
|
117
|
+
camera.lookAt(0, 0, 0)
|
|
118
|
+
camera.updateMatrixWorld(true)
|
|
119
|
+
|
|
120
|
+
const direction = camera.getWorldDirection(new Vector3())
|
|
121
|
+
const right = new Vector3(1, 0, 0).applyQuaternion(camera.quaternion)
|
|
122
|
+
const up = new Vector3(0, 1, 0).applyQuaternion(camera.quaternion)
|
|
123
|
+
const rayOrigin = camera.position.clone().addScaledVector(right, 3).addScaledVector(up, 2)
|
|
124
|
+
const groundDistance = -rayOrigin.y / direction.y
|
|
125
|
+
const worldHit = rayOrigin.clone().addScaledVector(direction, groundDistance)
|
|
126
|
+
const topDistance = (2 - rayOrigin.y) / direction.y
|
|
127
|
+
const expectedTop = rayOrigin.clone().addScaledVector(direction, topDistance)
|
|
128
|
+
|
|
129
|
+
addPluginPlatform(expectedTop.z, [0.5, 2, 0.5])
|
|
130
|
+
const platformMesh = sceneRegistry.nodes.get(PLATFORM_ID)!
|
|
131
|
+
platformMesh.position.set(expectedTop.x, 1, expectedTop.z)
|
|
132
|
+
platformMesh.updateMatrixWorld(true)
|
|
133
|
+
|
|
134
|
+
const support = resolvePointerSupportSurface(
|
|
135
|
+
camera,
|
|
136
|
+
worldHit.toArray() as [number, number, number],
|
|
137
|
+
{
|
|
138
|
+
includeNodeTopSurfaces: true,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(support?.sourceNodeId).toBe(PLATFORM_ID)
|
|
143
|
+
expect(support?.worldPoint?.[0]).toBeCloseTo(expectedTop.x)
|
|
144
|
+
expect(support?.worldPoint?.[1]).toBeCloseTo(expectedTop.y)
|
|
145
|
+
expect(support?.worldPoint?.[2]).toBeCloseTo(expectedTop.z)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('discovers a plugin-declared top surface without a kind-name list', () => {
|
|
149
|
+
addPluginPlatform()
|
|
68
150
|
|
|
69
151
|
const camera = new PerspectiveCamera()
|
|
70
152
|
camera.position.set(0, 5, 0)
|
|
@@ -74,25 +156,245 @@ describe('resolvePointerSupportSurface node tops', () => {
|
|
|
74
156
|
includeNodeTopSurfaces: true,
|
|
75
157
|
})
|
|
76
158
|
|
|
77
|
-
expect(support?.sourceNodeId).toBe(
|
|
159
|
+
expect(support?.sourceNodeId).toBe(PLATFORM_ID)
|
|
78
160
|
expect(support?.elevation).toBeCloseTo(2)
|
|
79
161
|
expect(support?.worldPoint).toEqual([0, 2, 0])
|
|
80
162
|
})
|
|
81
163
|
|
|
82
|
-
test('keeps
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
164
|
+
test('keeps an unhovered item top above a batched slab across batch transitions', () => {
|
|
165
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
166
|
+
const item = ItemNode.parse({
|
|
167
|
+
id: 'item_support_batch',
|
|
168
|
+
parentId: LEVEL_ID,
|
|
169
|
+
asset: {
|
|
170
|
+
id: 'table',
|
|
171
|
+
name: 'Table',
|
|
172
|
+
src: '/table.glb',
|
|
173
|
+
category: 'furniture',
|
|
174
|
+
thumbnail: '/table.png',
|
|
175
|
+
dimensions: [4, 2, 4],
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
const slab = {
|
|
179
|
+
id: 'slab_support_batch',
|
|
180
|
+
type: 'slab',
|
|
181
|
+
parentId: LEVEL_ID,
|
|
182
|
+
polygon: [
|
|
183
|
+
[-3, -3],
|
|
184
|
+
[3, -3],
|
|
185
|
+
[3, 3],
|
|
186
|
+
[-3, 3],
|
|
187
|
+
],
|
|
188
|
+
elevation: 0.25,
|
|
189
|
+
thickness: 0.25,
|
|
190
|
+
holes: [],
|
|
191
|
+
visible: true,
|
|
192
|
+
} as unknown as SlabNode
|
|
193
|
+
const itemMesh = new Mesh(new BoxGeometry(4, 2, 4), new MeshBasicMaterial())
|
|
194
|
+
itemMesh.position.y = 1.25
|
|
195
|
+
itemMesh.updateMatrixWorld(true)
|
|
196
|
+
const slabMesh = new Mesh(new BoxGeometry(6, 0.25, 6), new MeshBasicMaterial())
|
|
197
|
+
slabMesh.position.y = 0.125
|
|
198
|
+
slabMesh.updateMatrixWorld(true)
|
|
199
|
+
try {
|
|
200
|
+
registerNode({
|
|
201
|
+
kind: 'item',
|
|
202
|
+
schema: ItemNode,
|
|
203
|
+
schemaVersion: 1,
|
|
204
|
+
category: 'furnish',
|
|
205
|
+
defaults: () => ({}),
|
|
206
|
+
capabilities: { surfaces: { top: { height: 2 } } },
|
|
207
|
+
} as unknown as AnyNodeDefinition)
|
|
208
|
+
useScene.setState((state) => ({
|
|
209
|
+
nodes: { ...state.nodes, [item.id]: item, [slab.id]: slab },
|
|
210
|
+
}))
|
|
211
|
+
sceneRegistry.nodes.set(item.id, itemMesh)
|
|
212
|
+
sceneRegistry.byType.item!.add(item.id)
|
|
213
|
+
sceneRegistry.nodes.set(slab.id, slabMesh)
|
|
214
|
+
sceneRegistry.byType.slab!.add(slab.id)
|
|
215
|
+
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
|
|
216
|
+
useViewer.setState({ hoveredId: null })
|
|
217
|
+
const camera = new PerspectiveCamera()
|
|
218
|
+
camera.position.set(0, 5, 0)
|
|
219
|
+
camera.updateMatrixWorld(true)
|
|
220
|
+
for (const batched of [false, true, false]) {
|
|
221
|
+
for (const mesh of [itemMesh, slabMesh]) {
|
|
222
|
+
if (batched) hideFromScene(mesh, 'batched')
|
|
223
|
+
else showInScene(mesh, 'batched')
|
|
224
|
+
}
|
|
225
|
+
const top = resolvePointerSupportSurface(camera, [0, 0, 0], {
|
|
226
|
+
includeNodeTopSurfaces: true,
|
|
227
|
+
})
|
|
228
|
+
expect(top?.sourceNodeId).toBe(item.id)
|
|
229
|
+
expect(top?.worldPoint).toEqual([0, 2.25, 0])
|
|
230
|
+
const floor = resolvePointerSupportSurface(camera, [0, 0, 0])
|
|
231
|
+
expect(floor?.supportSlabId).toBe(slab.id)
|
|
232
|
+
expect(floor?.worldPoint).toEqual([0, 0.25, 0])
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
restoreRegistry()
|
|
236
|
+
for (const mesh of [itemMesh, slabMesh]) {
|
|
237
|
+
mesh.geometry.dispose()
|
|
238
|
+
mesh.material.dispose()
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('keeps the ground result unless node-top surfaces are asked for', () => {
|
|
244
|
+
addPluginPlatform()
|
|
245
|
+
|
|
246
|
+
const camera = new PerspectiveCamera()
|
|
247
|
+
camera.position.set(0, 5, 0)
|
|
248
|
+
camera.updateMatrixWorld(true)
|
|
249
|
+
|
|
250
|
+
// The default. A floor placement aims THROUGH whatever upward-facing
|
|
251
|
+
// geometry sits between the camera and the floor — a room's ceiling, the
|
|
252
|
+
// top of the wall the ray passes over — so only the tools that build on a
|
|
253
|
+
// surface opt in.
|
|
254
|
+
for (const options of [undefined, { includeNodeTopSurfaces: false }]) {
|
|
255
|
+
const support = resolvePointerSupportSurface(camera, [0, 0, 0], options)
|
|
256
|
+
expect(support?.sourceNodeId).toBeNull()
|
|
257
|
+
expect(support?.elevation).toBe(0)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('never elects the node the active interaction is placing or moving', () => {
|
|
262
|
+
addPluginPlatform()
|
|
263
|
+
useInteractionScope.getState().begin({
|
|
264
|
+
kind: 'placing',
|
|
265
|
+
node: useScene.getState().nodes[PLATFORM_ID]!,
|
|
266
|
+
nodeId: PLATFORM_ID,
|
|
267
|
+
nodeType: PLATFORM_KIND,
|
|
268
|
+
view: '3d',
|
|
269
|
+
pressDrag: false,
|
|
270
|
+
driver: 'registry-tool',
|
|
271
|
+
})
|
|
88
272
|
|
|
89
273
|
const camera = new PerspectiveCamera()
|
|
90
274
|
camera.position.set(0, 5, 0)
|
|
91
275
|
camera.updateMatrixWorld(true)
|
|
92
276
|
|
|
93
|
-
const support = resolvePointerSupportSurface(camera, [0, 0, 0]
|
|
277
|
+
const support = resolvePointerSupportSurface(camera, [0, 0, 0], {
|
|
278
|
+
includeNodeTopSurfaces: true,
|
|
279
|
+
})
|
|
94
280
|
|
|
281
|
+
// Its own top would raise it by its own height on every pointer move.
|
|
95
282
|
expect(support?.sourceNodeId).toBeNull()
|
|
96
283
|
expect(support?.elevation).toBe(0)
|
|
97
284
|
})
|
|
285
|
+
|
|
286
|
+
test('uses a plugin-declared top as the wall construction surface', () => {
|
|
287
|
+
const lowSlab = {
|
|
288
|
+
id: 'slab_low',
|
|
289
|
+
type: 'slab',
|
|
290
|
+
object: 'node',
|
|
291
|
+
parentId: LEVEL_ID,
|
|
292
|
+
visible: true,
|
|
293
|
+
metadata: {},
|
|
294
|
+
children: [],
|
|
295
|
+
polygon: [
|
|
296
|
+
[-2, 1],
|
|
297
|
+
[2, 1],
|
|
298
|
+
[2, 3],
|
|
299
|
+
[-2, 3],
|
|
300
|
+
],
|
|
301
|
+
holes: [],
|
|
302
|
+
holeMetadata: [],
|
|
303
|
+
elevation: 0.25,
|
|
304
|
+
thickness: 0.25,
|
|
305
|
+
recessed: false,
|
|
306
|
+
autoFromWalls: false,
|
|
307
|
+
} as SlabNode
|
|
308
|
+
const highSlab = {
|
|
309
|
+
...lowSlab,
|
|
310
|
+
id: 'slab_high',
|
|
311
|
+
polygon: [
|
|
312
|
+
[-1, 1],
|
|
313
|
+
[0, 1],
|
|
314
|
+
[0, 3],
|
|
315
|
+
[-1, 3],
|
|
316
|
+
],
|
|
317
|
+
elevation: 1,
|
|
318
|
+
} as SlabNode
|
|
319
|
+
useScene.setState((state) => ({
|
|
320
|
+
nodes: {
|
|
321
|
+
...state.nodes,
|
|
322
|
+
[lowSlab.id]: lowSlab,
|
|
323
|
+
[highSlab.id]: highSlab,
|
|
324
|
+
},
|
|
325
|
+
}))
|
|
326
|
+
spatialGridManager.handleNodeCreated(lowSlab as AnyNode, LEVEL_ID)
|
|
327
|
+
spatialGridManager.handleNodeCreated(highSlab as AnyNode, LEVEL_ID)
|
|
328
|
+
addPluginPlatform(2)
|
|
329
|
+
|
|
330
|
+
const camera = new PerspectiveCamera()
|
|
331
|
+
camera.position.set(0, 5, 2)
|
|
332
|
+
camera.updateMatrixWorld(true)
|
|
333
|
+
|
|
334
|
+
const support = resolvePointerSupportSurface(camera, [0, 0, 2], {
|
|
335
|
+
includeNodeTopSurfaces: true,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(support?.sourceNodeId).toBe(PLATFORM_ID)
|
|
339
|
+
expect(support?.elevation).toBeCloseTo(2)
|
|
340
|
+
expect(support?.worldPoint).toEqual([0, 2, 2])
|
|
341
|
+
|
|
342
|
+
const wall = createWallOnCurrentLevel([-0.75, 2], [0.75, 2], {
|
|
343
|
+
supportCap: support?.elevation,
|
|
344
|
+
preferredSupportSlabId: support?.supportSlabId,
|
|
345
|
+
constructionElevation: support?.elevation,
|
|
346
|
+
constructionHeight: 2.5,
|
|
347
|
+
flatConstructionBase: support?.sourceNodeId != null,
|
|
348
|
+
})
|
|
349
|
+
expect(wall).not.toBeNull()
|
|
350
|
+
expect(getWallBaseElevationForNodes(wall!, useScene.getState().nodes)).toBeCloseTo(2)
|
|
351
|
+
const wallSupport = spatialGridManager.getSlabSupportForWall(
|
|
352
|
+
LEVEL_ID,
|
|
353
|
+
wall!.start,
|
|
354
|
+
wall!.end,
|
|
355
|
+
wall!.curveOffset,
|
|
356
|
+
wall!.thickness,
|
|
357
|
+
wall!.supportSlabId,
|
|
358
|
+
undefined,
|
|
359
|
+
wall!.supportOffset,
|
|
360
|
+
)
|
|
361
|
+
expect(
|
|
362
|
+
wallSupport.baseSegments.every((segment) => Math.abs(segment.elevation - 2) < 1e-6),
|
|
363
|
+
).toBe(true)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('tolerates a registered definition without capabilities', () => {
|
|
367
|
+
// Gates the pollution class behind the night-8 CI flake (run
|
|
368
|
+
// 32580694134): `capabilities` is typed required, but a minimal plugin
|
|
369
|
+
// definition (or a leaked test fixture) can ship without it at runtime.
|
|
370
|
+
// The resolver enumerates every registered kind, so one capabilities-less
|
|
371
|
+
// entry must read as "no top surface" — not crash the election.
|
|
372
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
373
|
+
try {
|
|
374
|
+
registerNode({
|
|
375
|
+
kind: 'plugin-minimal-capless',
|
|
376
|
+
schemaVersion: 1,
|
|
377
|
+
schema: z.object({}),
|
|
378
|
+
category: 'structure',
|
|
379
|
+
defaults: () => ({}),
|
|
380
|
+
// No `capabilities` — deliberately.
|
|
381
|
+
} as unknown as AnyNodeDefinition)
|
|
382
|
+
addPluginPlatform()
|
|
383
|
+
|
|
384
|
+
const camera = new PerspectiveCamera()
|
|
385
|
+
camera.position.set(0, 5, 0)
|
|
386
|
+
camera.updateMatrixWorld(true)
|
|
387
|
+
|
|
388
|
+
const support = resolvePointerSupportSurface(camera, [0, 0, 0], {
|
|
389
|
+
includeNodeTopSurfaces: true,
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
// No throw, and the election still works: the capabilities-less kind is
|
|
393
|
+
// skipped while the platform's declared top is found as usual.
|
|
394
|
+
expect(support?.sourceNodeId).toBe(PLATFORM_ID)
|
|
395
|
+
expect(support?.elevation).toBeCloseTo(2)
|
|
396
|
+
} finally {
|
|
397
|
+
restoreRegistry()
|
|
398
|
+
}
|
|
399
|
+
})
|
|
98
400
|
})
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type AnyNode,
|
|
2
3
|
type AnyNodeId,
|
|
3
4
|
canHostOnTop,
|
|
4
5
|
GROUND_SUPPORT_ID,
|
|
5
6
|
type ItemNode,
|
|
6
7
|
isLowProfileItemSurface,
|
|
8
|
+
nodeRegistry,
|
|
7
9
|
sceneRegistry,
|
|
8
10
|
spatialGridManager,
|
|
9
11
|
useScene,
|
|
10
12
|
} from '@pascal-app/core'
|
|
11
|
-
import { useViewer } from '@pascal-app/viewer'
|
|
12
|
-
import { type Camera,
|
|
13
|
+
import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { type Camera, type Object3D, Raycaster, Vector3 } from 'three'
|
|
13
15
|
import { resolveTerrainGroundHit } from '../../../lib/ground-surface'
|
|
16
|
+
import { scopeNodeId } from '../../../lib/interaction/scope'
|
|
17
|
+
import useInteractionScope from '../../../store/use-interaction-scope'
|
|
18
|
+
import { surfaceWorldNormalY } from './surface-hit'
|
|
14
19
|
|
|
15
20
|
const originScratch = new Vector3()
|
|
16
21
|
const hitScratch = new Vector3()
|
|
@@ -19,10 +24,7 @@ const pointScratch = new Vector3()
|
|
|
19
24
|
const worldRayOrigin = new Vector3()
|
|
20
25
|
const worldRayDirection = new Vector3()
|
|
21
26
|
const nodeTopRaycaster = new Raycaster()
|
|
22
|
-
|
|
23
|
-
const nodeTopNormalMatrix = new Matrix3()
|
|
24
|
-
|
|
25
|
-
const NODE_TOP_SURFACE_KINDS = ['wall', 'item', 'column'] as const
|
|
27
|
+
setSurfaceRaycastLayers(nodeTopRaycaster.layers)
|
|
26
28
|
|
|
27
29
|
export type PointerSupportSurface = {
|
|
28
30
|
/** Level-local elevation of the pointed surface — the election cap. */
|
|
@@ -85,7 +87,19 @@ export function resolvePointerSupportSurface(
|
|
|
85
87
|
// The world ray, kept before the level conversion below: the terrain field is
|
|
86
88
|
// world-space (site geometry, not level-local), so the march needs this frame.
|
|
87
89
|
camera.getWorldPosition(worldRayOrigin)
|
|
88
|
-
|
|
90
|
+
const cameraToHit = hitScratch.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin)
|
|
91
|
+
if ((camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera) {
|
|
92
|
+
// For an orthographic camera every screen pixel has the same direction. The
|
|
93
|
+
// hit point is offset from the camera along the view plane, so using
|
|
94
|
+
// `camera.position -> hit` tilts the ray toward the screen centre and makes
|
|
95
|
+
// support surfaces drift away from the cursor off-axis.
|
|
96
|
+
camera.getWorldDirection(worldRayDirection).normalize()
|
|
97
|
+
worldRayOrigin
|
|
98
|
+
.set(worldHit[0], worldHit[1], worldHit[2])
|
|
99
|
+
.addScaledVector(worldRayDirection, -cameraToHit.dot(worldRayDirection))
|
|
100
|
+
} else {
|
|
101
|
+
worldRayDirection.copy(cameraToHit).normalize()
|
|
102
|
+
}
|
|
89
103
|
|
|
90
104
|
originScratch.copy(worldRayOrigin)
|
|
91
105
|
hitScratch.set(worldHit[0], worldHit[1], worldHit[2])
|
|
@@ -151,16 +165,39 @@ export function resolvePointerSupportSurface(
|
|
|
151
165
|
localPoint = [pointScratch.x, pointScratch.y, pointScratch.z]
|
|
152
166
|
}
|
|
153
167
|
|
|
154
|
-
|
|
168
|
+
// Node tops are opt-in. A ray aimed at a floor crosses every upward-facing
|
|
169
|
+
// face above that floor first — a room's ceiling, the top of the wall it
|
|
170
|
+
// passes over — so electing "the nearest node top along the ray" silently
|
|
171
|
+
// lifts anything placed inside a finished room. Only the tools that build ON
|
|
172
|
+
// a surface (wall / column / fence / stair / block) mean that, and they say
|
|
173
|
+
// so. Everything else places against the floor the pointer indicates.
|
|
174
|
+
//
|
|
175
|
+
// `capabilities` is typed required on NodeDefinition, but this enumerates
|
|
176
|
+
// EVERY registered kind — including plugin bundles that bypass the type at
|
|
177
|
+
// runtime. A minimal definition without `capabilities` must read as "no top
|
|
178
|
+
// surface", not crash the resolver (night-8 CI, run 32580694134).
|
|
179
|
+
const nodeTopSurfaceKinds = options?.includeNodeTopSurfaces
|
|
180
|
+
? Array.from(nodeRegistry.entries())
|
|
181
|
+
.filter(([, definition]) => definition.capabilities?.surfaces?.top !== undefined)
|
|
182
|
+
.map(([kind]) => kind)
|
|
183
|
+
: []
|
|
184
|
+
if (nodeTopSurfaceKinds.some((kind) => (sceneRegistry.byType[kind]?.size ?? 0) > 0)) {
|
|
155
185
|
nodeTopRaycaster.set(worldRayOrigin, worldRayDirection.clone().normalize())
|
|
156
186
|
const nodes = useScene.getState().nodes
|
|
157
187
|
const registeredOwners = new Map(
|
|
158
188
|
[...sceneRegistry.nodes.entries()].map(([nodeId, object]) => [object, nodeId as AnyNodeId]),
|
|
159
189
|
)
|
|
160
|
-
|
|
161
|
-
|
|
190
|
+
// The node the active interaction is placing/moving cannot be a surface for
|
|
191
|
+
// itself: its mesh rides the cursor, so electing its own top would raise it
|
|
192
|
+
// by its own height on every pointer move. Tools neuter the dragged mesh's
|
|
193
|
+
// `raycast` for their own pointer routing, but that is each tool's private
|
|
194
|
+
// convention — the election owns the invariant.
|
|
195
|
+
const interactingNodeId = scopeNodeId(useInteractionScope.getState().scope)
|
|
196
|
+
const isEligibleCandidate = (nodeId: AnyNodeId) => {
|
|
197
|
+
let current: AnyNode | undefined = nodes[nodeId]
|
|
162
198
|
const visited = new Set<AnyNodeId>()
|
|
163
199
|
while (current && !visited.has(current.id)) {
|
|
200
|
+
if (current.id === interactingNodeId) return false
|
|
164
201
|
if (current.id === levelId) return true
|
|
165
202
|
visited.add(current.id)
|
|
166
203
|
current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined
|
|
@@ -179,12 +216,12 @@ export function resolvePointerSupportSurface(
|
|
|
179
216
|
}
|
|
180
217
|
| undefined
|
|
181
218
|
|
|
182
|
-
for (const kind of
|
|
219
|
+
for (const kind of nodeTopSurfaceKinds) {
|
|
183
220
|
for (const rawId of sceneRegistry.byType[kind] ?? []) {
|
|
184
221
|
const nodeId = rawId as AnyNodeId
|
|
185
222
|
const node = nodes[nodeId]
|
|
186
223
|
const object = sceneRegistry.nodes.get(nodeId)
|
|
187
|
-
if (!(node?.visible && object?.visible &&
|
|
224
|
+
if (!(node?.visible && object?.visible && isEligibleCandidate(nodeId))) continue
|
|
188
225
|
if (
|
|
189
226
|
node.type === 'item' &&
|
|
190
227
|
(!canHostOnTop(node) || isLowProfileItemSurface(node as ItemNode))
|
|
@@ -210,11 +247,13 @@ export function resolvePointerSupportSurface(
|
|
|
210
247
|
ownerObject = ownerObject.parent
|
|
211
248
|
}
|
|
212
249
|
if (belongsToNestedNode) continue
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
250
|
+
if (
|
|
251
|
+
surfaceWorldNormalY(
|
|
252
|
+
intersection.face.normal.toArray(),
|
|
253
|
+
intersection.object.matrixWorld,
|
|
254
|
+
) < 0.75
|
|
255
|
+
)
|
|
256
|
+
continue
|
|
218
257
|
nearest = {
|
|
219
258
|
distance: intersection.distance,
|
|
220
259
|
nodeId,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
createSceneApi,
|
|
4
|
+
getSurfaceProvider,
|
|
5
|
+
sceneRegistry,
|
|
6
|
+
surfaceRegionContainsPoint,
|
|
7
|
+
useScene,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { Box3, type Camera, Matrix4, Ray, Vector3 } from 'three'
|
|
10
|
+
|
|
11
|
+
export function createShelfStickiness() {
|
|
12
|
+
const ray = new Ray()
|
|
13
|
+
const box = new Box3()
|
|
14
|
+
const matrix = new Matrix4()
|
|
15
|
+
const cameraPosition = new Vector3()
|
|
16
|
+
|
|
17
|
+
// A ray through a board gap can hit the floor behind the shelf while still targeting its volume.
|
|
18
|
+
return (
|
|
19
|
+
shelfId: string | null | undefined,
|
|
20
|
+
camera: Camera,
|
|
21
|
+
worldPoint: readonly [number, number, number],
|
|
22
|
+
) => {
|
|
23
|
+
if (!shelfId) return false
|
|
24
|
+
const mesh = sceneRegistry.nodes.get(shelfId)
|
|
25
|
+
const shelf = useScene.getState().nodes[shelfId as AnyNodeId]
|
|
26
|
+
if (!mesh || !shelf || (shelf.type !== 'shelf' && shelf.type !== 'cabinet')) return false
|
|
27
|
+
camera.getWorldPosition(cameraPosition)
|
|
28
|
+
ray.origin.copy(cameraPosition)
|
|
29
|
+
ray.direction
|
|
30
|
+
.set(...worldPoint)
|
|
31
|
+
.sub(cameraPosition)
|
|
32
|
+
.normalize()
|
|
33
|
+
ray.applyMatrix4(matrix.copy(mesh.matrixWorld).invert())
|
|
34
|
+
if (shelf.type === 'cabinet') {
|
|
35
|
+
const surfaces =
|
|
36
|
+
getSurfaceProvider(shelf).surfaces?.(shelf, { scene: createSceneApi(useScene) }) ?? []
|
|
37
|
+
return surfaces.some((surface) => {
|
|
38
|
+
if (Math.abs(ray.direction.y) < 1e-8) return false
|
|
39
|
+
const distance = (surface.position[1] - ray.origin.y) / ray.direction.y
|
|
40
|
+
if (distance < 0) return false
|
|
41
|
+
const point = ray.at(distance, new Vector3())
|
|
42
|
+
return surfaceRegionContainsPoint(surface.region, [
|
|
43
|
+
point.x - surface.position[0],
|
|
44
|
+
point.z - surface.position[2],
|
|
45
|
+
])
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
if (!shelf.width || !shelf.depth || !shelf.height) return false
|
|
49
|
+
const margin = 0.08
|
|
50
|
+
box.min.set(-shelf.width / 2 - margin, -margin, -shelf.depth / 2 - margin)
|
|
51
|
+
box.max.set(shelf.width / 2 + margin, shelf.height + margin, shelf.depth / 2 + margin)
|
|
52
|
+
return ray.intersectsBox(box)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { type ItemEvent, ItemNode, sceneRegistry } from '@pascal-app/core'
|
|
3
|
+
import { Group, Matrix3, Vector3 } from 'three'
|
|
4
|
+
import { itemEventToSurfaceHit } from './surface-hit'
|
|
5
|
+
|
|
6
|
+
const host = ItemNode.parse({
|
|
7
|
+
id: 'item_hit-adapter',
|
|
8
|
+
asset: { id: 'host', name: 'Host', category: 'furniture', thumbnail: '', src: '/host.glb' },
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => sceneRegistry.nodes.delete(host.id))
|
|
12
|
+
|
|
13
|
+
test('matches the legacy host-local point and hit-mesh normal exactly under rotation and scale', () => {
|
|
14
|
+
const mesh = new Group()
|
|
15
|
+
mesh.position.set(4.3, 2.1, -3.7)
|
|
16
|
+
mesh.rotation.set(0.2, 0.7, -0.1)
|
|
17
|
+
mesh.scale.set(2, 0.7, 1.3)
|
|
18
|
+
const object = new Group()
|
|
19
|
+
object.name = 'surface-mesh'
|
|
20
|
+
object.position.set(0.4, 0.8, -0.2)
|
|
21
|
+
object.rotation.set(-0.1, 0.3, 0.2)
|
|
22
|
+
object.scale.set(0.8, 1.4, 0.6)
|
|
23
|
+
mesh.add(object)
|
|
24
|
+
mesh.updateMatrixWorld(true)
|
|
25
|
+
sceneRegistry.nodes.set(host.id, mesh)
|
|
26
|
+
const event = {
|
|
27
|
+
node: host,
|
|
28
|
+
object,
|
|
29
|
+
position: object.localToWorld(new Vector3(0.37, 0.81, -0.39)).toArray(),
|
|
30
|
+
localPosition: [0.37, 0.81, -0.39],
|
|
31
|
+
normal: [0.2, 1, -0.3],
|
|
32
|
+
nativeEvent: {} as ItemEvent['nativeEvent'],
|
|
33
|
+
stopPropagation() {},
|
|
34
|
+
} as ItemEvent
|
|
35
|
+
const before = structuredClone({ position: event.position, normal: event.normal })
|
|
36
|
+
const expectedPoint = mesh.worldToLocal(new Vector3(...event.position)).toArray()
|
|
37
|
+
const expectedNormal = new Vector3(...event.normal!)
|
|
38
|
+
.applyNormalMatrix(new Matrix3().getNormalMatrix(object.matrixWorld))
|
|
39
|
+
.normalize().y
|
|
40
|
+
const hit = itemEventToSurfaceHit(host, event)!
|
|
41
|
+
expect(hit.point).toEqual(expectedPoint)
|
|
42
|
+
expect(hit.point).not.toEqual(event.localPosition)
|
|
43
|
+
expect(hit.normalWorldY).toBe(expectedNormal)
|
|
44
|
+
expect(hit.meshName).toBe(object.name)
|
|
45
|
+
expect({ position: event.position, normal: event.normal }).toEqual(before)
|
|
46
|
+
|
|
47
|
+
const roundTrip = mesh.localToWorld(new Vector3(...expectedPoint)).toArray()
|
|
48
|
+
expect(itemEventToSurfaceHit(host, { ...event, position: roundTrip })!.point).toEqual(
|
|
49
|
+
mesh.worldToLocal(new Vector3(...roundTrip)).toArray(),
|
|
50
|
+
)
|
|
51
|
+
expect(itemEventToSurfaceHit(host, { ...event, normal: undefined })!.normalWorldY).toBeNaN()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a missing registered host mesh yields no hit', () => {
|
|
55
|
+
expect(itemEventToSurfaceHit(host, {} as ItemEvent)).toBeNull()
|
|
56
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type AnyNode, type NodeEvent, type SurfaceHit, sceneRegistry } from '@pascal-app/core'
|
|
2
|
+
import { Matrix3, type Matrix4, Vector3 } from 'three'
|
|
3
|
+
|
|
4
|
+
export function surfaceWorldNormalY(
|
|
5
|
+
normal: NodeEvent<AnyNode>['normal'],
|
|
6
|
+
matrixWorld: Matrix4,
|
|
7
|
+
): number {
|
|
8
|
+
return normal
|
|
9
|
+
? new Vector3(...normal)
|
|
10
|
+
.applyNormalMatrix(new Matrix3().getNormalMatrix(matrixWorld))
|
|
11
|
+
.normalize().y
|
|
12
|
+
: Number.NaN
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function itemEventToSurfaceHit(host: AnyNode, event: NodeEvent<AnyNode>): SurfaceHit | null {
|
|
16
|
+
const mesh = sceneRegistry.nodes.get(host.id)
|
|
17
|
+
if (!mesh) return null
|
|
18
|
+
return {
|
|
19
|
+
point: mesh.worldToLocal(new Vector3(...event.position)).toArray(),
|
|
20
|
+
normalWorldY: surfaceWorldNormalY(event.normal, event.object.matrixWorld),
|
|
21
|
+
meshName: event.object.name,
|
|
22
|
+
}
|
|
23
|
+
}
|