@pascal-app/viewer 1.0.3 → 1.0.5
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/dist/capture/capture-runtime.js +1 -1
- package/dist/components/error-boundary.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.d.ts +1 -1
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +12 -4
- package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
- package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.d.ts +1 -0
- package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.js +22 -3
- package/dist/components/viewer/glb-interactive.d.ts +25 -1
- package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
- package/dist/components/viewer/glb-interactive.js +253 -86
- package/dist/components/viewer/glb-mechanisms.d.ts +10 -0
- package/dist/components/viewer/glb-mechanisms.d.ts.map +1 -0
- package/dist/components/viewer/glb-mechanisms.js +19 -0
- package/dist/components/viewer/glb-reference-nodes.js +1 -1
- package/dist/components/viewer/glb-replace-instances.js +1 -1
- package/dist/components/viewer/glb-scene.d.ts +45 -27
- package/dist/components/viewer/glb-scene.d.ts.map +1 -1
- package/dist/components/viewer/glb-scene.js +421 -508
- package/dist/components/viewer/glb-walkthrough-controller.d.ts +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.js +13 -2
- package/dist/components/viewer/index.d.ts +24 -1
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +111 -37
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +113 -22
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
- package/dist/components/viewer/perf-monitor.js +6 -0
- package/dist/components/viewer/perf-panel.d.ts.map +1 -1
- package/dist/components/viewer/perf-panel.js +1 -1
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +104 -36
- package/dist/components/viewer/registered-systems.d.ts.map +1 -1
- package/dist/components/viewer/registered-systems.js +17 -3
- package/dist/components/viewer/render-error.d.ts +18 -0
- package/dist/components/viewer/render-error.d.ts.map +1 -0
- package/dist/components/viewer/render-error.js +29 -0
- package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
- package/dist/components/viewer/scene-bvh.js +1 -1
- package/dist/components/viewer/scene-environment.d.ts.map +1 -1
- package/dist/components/viewer/scene-environment.js +31 -11
- package/dist/components/viewer/scene-ground-replacement.d.ts +6 -0
- package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -1
- package/dist/components/viewer/scene-ground-replacement.js +15 -0
- package/dist/components/viewer/selection-manager.d.ts.map +1 -1
- package/dist/components/viewer/selection-manager.js +25 -7
- package/dist/components/viewer/underground-ambiance.d.ts +9 -0
- package/dist/components/viewer/underground-ambiance.d.ts.map +1 -0
- package/dist/components/viewer/underground-ambiance.js +105 -0
- package/dist/components/viewer/viewer-camera.d.ts +4 -0
- package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
- package/dist/components/viewer/viewer-camera.js +12 -2
- package/dist/components/viewer/viewer-presentations.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +2 -0
- package/dist/hooks/use-node-events.d.ts.map +1 -1
- package/dist/index.d.ts +36 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -19
- package/dist/lib/asset-url.d.ts.map +1 -1
- package/dist/lib/asset-url.js +7 -1
- package/dist/lib/backdrop.d.ts +17 -0
- package/dist/lib/backdrop.d.ts.map +1 -1
- package/dist/lib/backdrop.js +18 -1
- package/dist/lib/construction-dust.d.ts +57 -0
- package/dist/lib/construction-dust.d.ts.map +1 -0
- package/dist/lib/construction-dust.js +187 -0
- package/dist/lib/csg-utils.js +1 -1
- package/dist/lib/display-state.d.ts +25 -0
- package/dist/lib/display-state.d.ts.map +1 -0
- package/dist/lib/display-state.js +1 -0
- package/dist/lib/foundation-materials.d.ts +26 -0
- package/dist/lib/foundation-materials.d.ts.map +1 -0
- package/dist/lib/foundation-materials.js +65 -0
- package/dist/lib/gltf-unlit.d.ts +10 -0
- package/dist/lib/gltf-unlit.d.ts.map +1 -0
- package/dist/lib/gltf-unlit.js +23 -0
- package/dist/lib/ground-window.d.ts +33 -0
- package/dist/lib/ground-window.d.ts.map +1 -0
- package/dist/lib/ground-window.js +114 -0
- package/dist/lib/hero-pose.d.ts +13 -3
- package/dist/lib/hero-pose.d.ts.map +1 -1
- package/dist/lib/hero-pose.js +72 -5
- package/dist/lib/ink-edges.d.ts +6 -3
- package/dist/lib/ink-edges.d.ts.map +1 -1
- package/dist/lib/ink-edges.js +17 -7
- package/dist/lib/key-light-override.d.ts +11 -0
- package/dist/lib/key-light-override.d.ts.map +1 -0
- package/dist/lib/key-light-override.js +14 -0
- package/dist/lib/ktx2-loader.d.ts +7 -0
- package/dist/lib/ktx2-loader.d.ts.map +1 -1
- package/dist/lib/ktx2-loader.js +35 -0
- package/dist/lib/layer-pass.d.ts.map +1 -1
- package/dist/lib/layers.d.ts +11 -7
- package/dist/lib/layers.d.ts.map +1 -1
- package/dist/lib/layers.js +16 -6
- package/dist/lib/live-frame-hold.d.ts +14 -0
- package/dist/lib/live-frame-hold.d.ts.map +1 -0
- package/dist/lib/live-frame-hold.js +28 -0
- package/dist/lib/materials.d.ts +19 -7
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +65 -9
- package/dist/lib/merged-outline-node.d.ts +5 -0
- package/dist/lib/merged-outline-node.d.ts.map +1 -1
- package/dist/lib/merged-outline-node.js +80 -2
- package/dist/lib/node-top-surface-height.d.ts +6 -0
- package/dist/lib/node-top-surface-height.d.ts.map +1 -0
- package/dist/lib/node-top-surface-height.js +74 -0
- package/dist/lib/perf-probe-state.d.ts +39 -0
- package/dist/lib/perf-probe-state.d.ts.map +1 -0
- package/dist/lib/perf-probe-state.js +17 -0
- package/dist/lib/pointer-events.d.ts +26 -2
- package/dist/lib/pointer-events.d.ts.map +1 -1
- package/dist/lib/pointer-events.js +99 -12
- package/dist/lib/post-processing-resources.d.ts.map +1 -1
- package/dist/lib/procedural-emission.d.ts +7 -0
- package/dist/lib/procedural-emission.d.ts.map +1 -0
- package/dist/lib/procedural-emission.js +54 -0
- package/dist/lib/reveal-candidates.d.ts +80 -0
- package/dist/lib/reveal-candidates.d.ts.map +1 -0
- package/dist/lib/reveal-candidates.js +524 -0
- package/dist/lib/reveal-ghost.d.ts +9 -0
- package/dist/lib/reveal-ghost.d.ts.map +1 -0
- package/dist/lib/reveal-ghost.js +62 -0
- package/dist/lib/reveal-pending.d.ts +12 -0
- package/dist/lib/reveal-pending.d.ts.map +1 -0
- package/dist/lib/reveal-pending.js +12 -0
- package/dist/lib/reveal-pose.d.ts +70 -0
- package/dist/lib/reveal-pose.d.ts.map +1 -0
- package/dist/lib/reveal-pose.js +248 -0
- package/dist/lib/reveal-schedule.d.ts +208 -0
- package/dist/lib/reveal-schedule.d.ts.map +1 -0
- package/dist/lib/reveal-schedule.js +410 -0
- package/dist/lib/reveal-texture-scale.d.ts +5 -0
- package/dist/lib/reveal-texture-scale.d.ts.map +1 -0
- package/dist/lib/reveal-texture-scale.js +113 -0
- package/dist/lib/reveal-windows.d.ts +23 -0
- package/dist/lib/reveal-windows.d.ts.map +1 -0
- package/dist/lib/reveal-windows.js +31 -0
- package/dist/lib/reveal-wipe.d.ts +71 -0
- package/dist/lib/reveal-wipe.d.ts.map +1 -0
- package/dist/lib/reveal-wipe.js +105 -0
- package/dist/lib/scene-pass-mrt.d.ts +12 -0
- package/dist/lib/scene-pass-mrt.d.ts.map +1 -0
- package/dist/lib/scene-pass-mrt.js +23 -0
- package/dist/lib/scene-support-height.d.ts +4 -0
- package/dist/lib/scene-support-height.d.ts.map +1 -0
- package/dist/lib/scene-support-height.js +34 -0
- package/dist/lib/scene-themes.d.ts +30 -8
- package/dist/lib/scene-themes.d.ts.map +1 -1
- package/dist/lib/scene-themes.js +36 -0
- package/dist/lib/scene-visibility.d.ts +17 -1
- package/dist/lib/scene-visibility.d.ts.map +1 -1
- package/dist/lib/scene-visibility.js +53 -3
- package/dist/lib/scripted-opening.d.ts +11 -0
- package/dist/lib/scripted-opening.d.ts.map +1 -0
- package/dist/lib/scripted-opening.js +20 -0
- package/dist/lib/site-surfaces.d.ts +32 -0
- package/dist/lib/site-surfaces.d.ts.map +1 -0
- package/dist/lib/site-surfaces.js +70 -0
- package/dist/lib/snapshot-pipeline.d.ts +34 -3
- package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
- package/dist/lib/snapshot-pipeline.js +247 -117
- package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -1
- package/dist/lib/terrain-perimeter-fill.js +22 -14
- package/dist/lib/tsl-compat.d.ts +2 -2
- package/dist/lib/tsl-compat.d.ts.map +1 -1
- package/dist/lib/underground-ambiance.d.ts +168 -0
- package/dist/lib/underground-ambiance.d.ts.map +1 -0
- package/dist/lib/underground-ambiance.js +334 -0
- package/dist/lib/zone-geometry.d.ts +11 -0
- package/dist/lib/zone-geometry.d.ts.map +1 -0
- package/dist/lib/zone-geometry.js +46 -0
- package/dist/store/use-item-light-pool.d.ts +14 -10
- package/dist/store/use-item-light-pool.d.ts.map +1 -1
- package/dist/store/use-item-light-pool.js +52 -26
- package/dist/store/use-viewer.d.ts +194 -26
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +164 -21
- package/dist/systems/ceiling/ceiling-system.d.ts +15 -1
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +88 -6
- package/dist/systems/construction-reveal/construction-dust.d.ts +16 -0
- package/dist/systems/construction-reveal/construction-dust.d.ts.map +1 -0
- package/dist/systems/construction-reveal/construction-dust.js +92 -0
- package/dist/systems/construction-reveal/construction-reveal.d.ts +276 -0
- package/dist/systems/construction-reveal/construction-reveal.d.ts.map +1 -0
- package/dist/systems/construction-reveal/construction-reveal.js +2383 -0
- package/dist/systems/construction-reveal/dust-material.d.ts +10 -0
- package/dist/systems/construction-reveal/dust-material.d.ts.map +1 -0
- package/dist/systems/construction-reveal/dust-material.js +17 -0
- package/dist/systems/construction-reveal/ghost-dress.d.ts +10 -0
- package/dist/systems/construction-reveal/ghost-dress.d.ts.map +1 -0
- package/dist/systems/construction-reveal/ghost-dress.js +24 -0
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +55 -17
- package/dist/systems/elevator/elevator-interaction-system.d.ts +7 -0
- package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -1
- package/dist/systems/elevator/elevator-interaction-system.js +18 -1
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts +3 -1
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
- package/dist/systems/floor-elevation/floor-elevation-system.js +33 -5
- package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
- package/dist/systems/geometry/geometry-system.js +71 -11
- package/dist/systems/interactive/control-widget.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +52 -17
- package/dist/systems/interactive/procedural-controls.d.ts +13 -0
- package/dist/systems/interactive/procedural-controls.d.ts.map +1 -0
- package/dist/systems/interactive/procedural-controls.js +17 -0
- package/dist/systems/interactive/scripted-clips.d.ts +19 -0
- package/dist/systems/interactive/scripted-clips.d.ts.map +1 -0
- package/dist/systems/interactive/scripted-clips.js +134 -0
- package/dist/systems/item/item-system.d.ts.map +1 -1
- package/dist/systems/item/item-system.js +9 -3
- package/dist/systems/item/object-cut-invalidation.d.ts +3 -0
- package/dist/systems/item/object-cut-invalidation.d.ts.map +1 -0
- package/dist/systems/item/object-cut-invalidation.js +68 -0
- package/dist/systems/item-light/item-light-system.d.ts +1 -1
- package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
- package/dist/systems/item-light/item-light-system.js +48 -53
- package/dist/systems/level/level-hold.d.ts +4 -0
- package/dist/systems/level/level-hold.d.ts.map +1 -0
- package/dist/systems/level/level-hold.js +14 -0
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +19 -6
- package/dist/systems/level/level-utils.d.ts +19 -6
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +68 -9
- package/dist/systems/level/shadow-only-levels.d.ts +9 -0
- package/dist/systems/level/shadow-only-levels.d.ts.map +1 -0
- package/dist/systems/level/shadow-only-levels.js +7 -0
- package/dist/systems/roof/__fixtures__/roof-plane-cases.d.ts +103 -0
- package/dist/systems/roof/__fixtures__/roof-plane-cases.d.ts.map +1 -0
- package/dist/systems/roof/__fixtures__/roof-plane-cases.js +317 -0
- package/dist/systems/roof/roof-materials.d.ts +16 -2
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +64 -31
- package/dist/systems/roof/roof-system.d.ts +21 -4
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +374 -49
- package/dist/systems/slab/slab-system.d.ts +1 -1
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +49 -19
- package/dist/systems/stair/stair-system.d.ts.map +1 -1
- package/dist/systems/stair/stair-system.js +133 -354
- package/dist/systems/wall/wall-cutout-cache.d.ts +39 -3
- package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout-cache.js +141 -19
- package/dist/systems/wall/wall-cutout.d.ts +6 -0
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +42 -28
- package/dist/systems/wall/wall-finish-data.d.ts +22 -0
- package/dist/systems/wall/wall-finish-data.d.ts.map +1 -0
- package/dist/systems/wall/wall-finish-data.js +17 -0
- package/dist/systems/wall/wall-material-variant.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.d.ts +9 -4
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +135 -153
- package/dist/systems/wall/wall-placeholder-sweep.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.d.ts +33 -2
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +621 -159
- package/dist/systems/window/corner-frame.d.ts +10 -0
- package/dist/systems/window/corner-frame.d.ts.map +1 -0
- package/dist/systems/window/corner-frame.js +17 -0
- package/dist/systems/window/window-system.d.ts +1 -1
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +71 -27
- package/package.json +10 -10
- package/dist/systems/fence/fence-system.d.ts +0 -22
- package/dist/systems/fence/fence-system.d.ts.map +0 -1
- package/dist/systems/fence/fence-system.js +0 -357
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { bakePolicyOf } from '@pascal-app/core';
|
|
4
|
-
import {
|
|
3
|
+
import { bakePolicyOf, containsPoint, distanceToBoundary, EXPLODED_GAP, itemInteraction, itemPrompt, operateItem, polygonInteriorPoint, useInteractive, } from '@pascal-app/core';
|
|
4
|
+
import { operableParts, ProceduralMotionController, } from '@pascal-app/core/procedural-items';
|
|
5
5
|
import { useFrame, useThree } from '@react-three/fiber';
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
7
7
|
import * as THREE from 'three';
|
|
8
8
|
import { lerp } from 'three/src/math/MathUtils.js';
|
|
9
|
-
import { color, float, uniform, uv } from 'three/tsl';
|
|
10
|
-
import { MeshBasicNodeMaterial } from 'three/webgpu';
|
|
11
9
|
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh';
|
|
12
10
|
import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2';
|
|
13
|
-
import { ZONE_LAYER } from '../../lib/layers';
|
|
14
11
|
import { createSurfaceRoleMaterial } from '../../lib/materials';
|
|
15
12
|
import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only';
|
|
16
13
|
import useViewer from '../../store/use-viewer';
|
|
14
|
+
import { useClipActions } from '../../systems/interactive/scripted-clips';
|
|
15
|
+
import { resolveLevelVisibility } from '../../systems/level/level-utils';
|
|
17
16
|
import { GlbInteractive } from './glb-interactive';
|
|
17
|
+
import { bakedLoopMechanisms } from './glb-mechanisms';
|
|
18
18
|
import { GlbReferenceNodes } from './glb-reference-nodes';
|
|
19
19
|
import { GlbReplaceInstances } from './glb-replace-instances';
|
|
20
|
-
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
|
|
21
|
-
const EXPLODED_GAP = 5;
|
|
22
20
|
/** Baked `kind` → surface role, so monochrome can recolor by role like the
|
|
23
21
|
* parametric viewer (textures-off collapses each face to its themed clay). */
|
|
24
22
|
const ROLE_BY_KIND = {
|
|
@@ -32,6 +30,8 @@ const ROLE_BY_KIND = {
|
|
|
32
30
|
door: 'joinery',
|
|
33
31
|
item: 'furnishing',
|
|
34
32
|
};
|
|
33
|
+
/** Kinds a pointer passes through to what lies behind (they frame, they don't get picked). */
|
|
34
|
+
const PASS_THROUGH_KINDS = new Set(['site', 'building', 'level', 'zone', 'ceiling', 'spawn']);
|
|
35
35
|
function findIdentityAncestor(object) {
|
|
36
36
|
let current = object;
|
|
37
37
|
while (current) {
|
|
@@ -41,6 +41,16 @@ function findIdentityAncestor(object) {
|
|
|
41
41
|
}
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
|
+
function findProceduralMotionAncestor(object) {
|
|
45
|
+
let current = object;
|
|
46
|
+
while (current) {
|
|
47
|
+
const motion = current.userData.proceduralMotion;
|
|
48
|
+
if (motion)
|
|
49
|
+
return motion;
|
|
50
|
+
current = current.parent;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
44
54
|
function findAncestorLevelId(object) {
|
|
45
55
|
let current = object.parent;
|
|
46
56
|
while (current) {
|
|
@@ -52,150 +62,142 @@ function findAncestorLevelId(object) {
|
|
|
52
62
|
return null;
|
|
53
63
|
}
|
|
54
64
|
const _local = new THREE.Vector3();
|
|
55
|
-
const _floorHit = new THREE.Vector3();
|
|
56
|
-
const _floorPlanePoint = new THREE.Vector3();
|
|
57
|
-
const _floorPlane = new THREE.Plane();
|
|
58
|
-
const _up = new THREE.Vector3(0, 1, 0);
|
|
59
|
-
const _bounds = new THREE.Box3();
|
|
60
|
-
const _boundsCenter = new THREE.Vector3();
|
|
61
|
-
const _sample = new THREE.Vector3();
|
|
62
65
|
const _camBox = new THREE.Box3();
|
|
63
66
|
const _camCenter = new THREE.Vector3();
|
|
64
67
|
const _camSize = new THREE.Vector3();
|
|
65
|
-
const _camPoint = new THREE.Vector3();
|
|
66
68
|
const _walkPos = new THREE.Vector3();
|
|
67
69
|
const _reticleNdc = new THREE.Vector2(0, 0);
|
|
68
70
|
const _reticleRaycaster = new THREE.Raycaster();
|
|
69
|
-
/** How far ahead (metres)
|
|
71
|
+
/** How far ahead (metres) an interactive part counts as "in view" for activation. */
|
|
70
72
|
const WALK_REACH = 3;
|
|
71
73
|
const ZONE_FOOTPRINT_EPSILON = 0.05;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
function pointInPolygonInclusive(x, z, polygon) {
|
|
96
|
-
if (pointInPolygon(x, z, polygon))
|
|
97
|
-
return true;
|
|
98
|
-
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
99
|
-
const [xi, zi] = polygon[i];
|
|
100
|
-
const [xj, zj] = polygon[j];
|
|
101
|
-
if (pointOnSegment(x, z, xi, zi, xj, zj))
|
|
102
|
-
return true;
|
|
103
|
-
}
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
function worldPointInZoneFootprint(worldPoint, zone) {
|
|
107
|
-
_local.copy(worldPoint);
|
|
108
|
-
zone.node.worldToLocal(_local);
|
|
109
|
-
return pointInPolygonInclusive(_local.x, _local.z, zone.polygon);
|
|
110
|
-
}
|
|
111
|
-
function objectFootprintTouchesZone(object, zone) {
|
|
112
|
-
_bounds.setFromObject(object);
|
|
113
|
-
if (_bounds.isEmpty()) {
|
|
114
|
-
object.getWorldPosition(_sample);
|
|
115
|
-
return worldPointInZoneFootprint(_sample, zone);
|
|
116
|
-
}
|
|
117
|
-
_bounds.getCenter(_boundsCenter);
|
|
118
|
-
const y = _bounds.min.y;
|
|
119
|
-
const samples = [
|
|
120
|
-
[_boundsCenter.x, _boundsCenter.z],
|
|
121
|
-
[_bounds.min.x, _bounds.min.z],
|
|
122
|
-
[_bounds.min.x, _bounds.max.z],
|
|
123
|
-
[_bounds.max.x, _bounds.min.z],
|
|
124
|
-
[_bounds.max.x, _bounds.max.z],
|
|
125
|
-
];
|
|
126
|
-
for (const [x, z] of samples) {
|
|
127
|
-
_sample.set(x, y, z);
|
|
128
|
-
if (worldPointInZoneFootprint(_sample, zone))
|
|
129
|
-
return true;
|
|
74
|
+
/**
|
|
75
|
+
* The baked node under a pointer that a click can land on: the nearest hit
|
|
76
|
+
* whose identity is not a kind that only frames (site, building, level, zone,
|
|
77
|
+
* ceiling) and is not on a hidden floor, with the hit in its level's local XZ.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveGlbPick(hits, identity) {
|
|
80
|
+
for (const hit of hits) {
|
|
81
|
+
const node = findIdentityAncestor(hit.object);
|
|
82
|
+
const extras = node?.userData;
|
|
83
|
+
if (!(node && extras?.pascalId) || PASS_THROUGH_KINDS.has(extras.kind ?? ''))
|
|
84
|
+
continue;
|
|
85
|
+
const levelId = findAncestorLevelId(node);
|
|
86
|
+
const level = levelId ? identity.get(levelId) : undefined;
|
|
87
|
+
if (level && !level.visible)
|
|
88
|
+
continue;
|
|
89
|
+
let point = null;
|
|
90
|
+
if (level) {
|
|
91
|
+
level.updateWorldMatrix(true, false);
|
|
92
|
+
_local.copy(hit.point);
|
|
93
|
+
level.worldToLocal(_local);
|
|
94
|
+
point = [_local.x, _local.z];
|
|
95
|
+
}
|
|
96
|
+
return { nodeId: extras.pascalId, point, hitObject: hit.object };
|
|
130
97
|
}
|
|
131
|
-
return
|
|
98
|
+
return null;
|
|
132
99
|
}
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
function createZoneFloorMaterial(zoneColor) {
|
|
137
|
-
const o = uniform(0);
|
|
138
|
-
const material = new MeshBasicNodeMaterial({
|
|
139
|
-
colorNode: color(new THREE.Color(zoneColor)),
|
|
140
|
-
depthTest: false,
|
|
141
|
-
depthWrite: false,
|
|
142
|
-
opacityNode: float(0.25).mul(o),
|
|
143
|
-
side: THREE.DoubleSide,
|
|
144
|
-
transparent: true,
|
|
145
|
-
});
|
|
146
|
-
material.userData.uOpacity = o;
|
|
147
|
-
return material;
|
|
148
|
-
}
|
|
149
|
-
/** Vertical border — color at the base fading to transparent at the top. */
|
|
150
|
-
function createZoneWallMaterial(zoneColor) {
|
|
151
|
-
const o = uniform(0);
|
|
152
|
-
const material = new MeshBasicNodeMaterial({
|
|
153
|
-
colorNode: color(new THREE.Color(zoneColor)),
|
|
154
|
-
depthTest: false,
|
|
155
|
-
depthWrite: false,
|
|
156
|
-
opacityNode: float(0.6).mul(float(1).sub(uv().y)).mul(o),
|
|
157
|
-
side: THREE.DoubleSide,
|
|
158
|
-
transparent: true,
|
|
159
|
-
});
|
|
160
|
-
material.userData.uOpacity = o;
|
|
161
|
-
return material;
|
|
162
|
-
}
|
|
163
|
-
/** Vertical quads along each polygon edge (UV.y 0 at the floor, 1 at the top). */
|
|
164
|
-
function createZoneWallGeometry(polygon) {
|
|
165
|
-
const positions = [];
|
|
166
|
-
const uvs = [];
|
|
167
|
-
const indices = [];
|
|
168
|
-
for (let i = 0; i < polygon.length; i++) {
|
|
169
|
-
const [cx, cz] = polygon[i];
|
|
170
|
-
const [nx, nz] = polygon[(i + 1) % polygon.length];
|
|
171
|
-
const base = i * 4;
|
|
172
|
-
positions.push(cx, Y_OFFSET, cz, nx, Y_OFFSET, nz, nx, Y_OFFSET + ZONE_WALL_HEIGHT, nz, cx, Y_OFFSET + ZONE_WALL_HEIGHT, cz);
|
|
173
|
-
uvs.push(0, 0, 1, 0, 1, 1, 0, 1);
|
|
174
|
-
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
|
|
175
|
-
}
|
|
176
|
-
const geometry = new THREE.BufferGeometry();
|
|
177
|
-
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
178
|
-
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
179
|
-
geometry.setIndex(indices);
|
|
180
|
-
geometry.computeVertexNormals();
|
|
181
|
-
return geometry;
|
|
100
|
+
function pointInZone(x, z, zone) {
|
|
101
|
+
const polygon = [{ outer: zone.polygon, holes: zone.holes }];
|
|
102
|
+
return (containsPoint(polygon, [x, z]) || distanceToBoundary(polygon, [x, z]) <= ZONE_FOOTPRINT_EPSILON);
|
|
182
103
|
}
|
|
183
104
|
/**
|
|
184
|
-
* GLB-consuming viewer scene
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* `
|
|
191
|
-
* host disables the parametric `SelectionManager` (`selectionManager="custom"`).
|
|
105
|
+
* GLB-consuming viewer scene. Loads a baked artifact and drives the viewer's
|
|
106
|
+
* presentation and interaction with no parametric scene graph. A pointer
|
|
107
|
+
* becomes a `GlbPickEvent` the host runs through its selection rules (the
|
|
108
|
+
* same rules as the parametric viewer); the shared `useViewer.selection` and
|
|
109
|
+
* `hoveredId` then drive the outline, and openables play their baked clips
|
|
110
|
+
* when clicked. The host disables the parametric `SelectionManager`
|
|
111
|
+
* (`selectionManager="custom"`).
|
|
192
112
|
*/
|
|
193
|
-
export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
113
|
+
export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes, onObjectsChange, onPick, onWalkthroughChange, }) {
|
|
194
114
|
const gltf = useGLTFKTX2(url);
|
|
195
115
|
const rootRef = useRef(null);
|
|
196
|
-
const
|
|
116
|
+
const actions = useClipActions(gltf.animations, rootRef);
|
|
117
|
+
const proceduralPlayback = useMemo(() => {
|
|
118
|
+
const byNode = new Map();
|
|
119
|
+
gltf.scene.traverse((object) => {
|
|
120
|
+
const extras = object.userData;
|
|
121
|
+
if (extras.kind === 'procedural-item' && extras.pascalId && !byNode.has(extras.pascalId))
|
|
122
|
+
byNode.set(extras.pascalId, { motions: [], clips: new Map() });
|
|
123
|
+
const motion = extras.proceduralMotion;
|
|
124
|
+
if (!motion?.clip)
|
|
125
|
+
return;
|
|
126
|
+
const clip = gltf.animations.find((entry) => entry.name === motion.clip);
|
|
127
|
+
if (!clip)
|
|
128
|
+
return;
|
|
129
|
+
const entry = byNode.get(motion.nodeId) ?? { motions: [], clips: new Map() };
|
|
130
|
+
if (motion.kind === 'spin') {
|
|
131
|
+
entry.motions.push({
|
|
132
|
+
id: motion.groupId,
|
|
133
|
+
partId: motion.partId,
|
|
134
|
+
kind: 'spin',
|
|
135
|
+
axis: 'y',
|
|
136
|
+
pivot: [0, 0, 0],
|
|
137
|
+
amount: (2 * Math.PI) / clip.duration,
|
|
138
|
+
delay: 0,
|
|
139
|
+
duration: 0,
|
|
140
|
+
easing: 'linear',
|
|
141
|
+
});
|
|
142
|
+
entry.clips.set(motion.clip, {
|
|
143
|
+
partId: motion.partId,
|
|
144
|
+
kind: 'spin',
|
|
145
|
+
groupId: entry.clips.get(motion.clip)?.groupId ?? motion.groupId,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
else if (motion.activeWindow &&
|
|
149
|
+
!entry.motions.some((item) => item.partId === motion.partId)) {
|
|
150
|
+
entry.motions.push({
|
|
151
|
+
id: motion.groupId,
|
|
152
|
+
partId: motion.partId,
|
|
153
|
+
kind: 'hinge',
|
|
154
|
+
axis: 'y',
|
|
155
|
+
pivot: [0, 0, 0],
|
|
156
|
+
amount: 1,
|
|
157
|
+
delay: motion.activeWindow[0],
|
|
158
|
+
duration: motion.activeWindow[1] - motion.activeWindow[0],
|
|
159
|
+
easing: 'linear',
|
|
160
|
+
});
|
|
161
|
+
entry.clips.set(motion.clip, {
|
|
162
|
+
partId: motion.partId,
|
|
163
|
+
kind: 'finite',
|
|
164
|
+
groupId: motion.groupId,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
byNode.set(motion.nodeId, entry);
|
|
168
|
+
});
|
|
169
|
+
// Procedural actions need a separate mixer so drei cannot advance their assigned times.
|
|
170
|
+
const mixer = new THREE.AnimationMixer(gltf.scene);
|
|
171
|
+
const entries = new Map([...byNode].map(([nodeId, entry]) => {
|
|
172
|
+
const spinParts = [
|
|
173
|
+
...new Set(entry.motions.filter((motion) => motion.kind === 'spin').map((motion) => motion.partId)),
|
|
174
|
+
];
|
|
175
|
+
const initial = Object.fromEntries(spinParts.map((partId) => [partId, true]));
|
|
176
|
+
return [
|
|
177
|
+
nodeId,
|
|
178
|
+
{
|
|
179
|
+
controller: new ProceduralMotionController(entry.motions, initial),
|
|
180
|
+
clips: entry.clips,
|
|
181
|
+
spinParts,
|
|
182
|
+
sequence: 0,
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
}));
|
|
186
|
+
const proceduralActions = new Map(gltf.animations
|
|
187
|
+
.filter((clip) => [...entries.values()].some((entry) => entry.clips.has(clip.name)))
|
|
188
|
+
.map((clip) => {
|
|
189
|
+
const action = mixer.clipAction(clip);
|
|
190
|
+
action.enabled = true;
|
|
191
|
+
action.paused = true;
|
|
192
|
+
action.loop = clip.name.endsWith(': loop') ? THREE.LoopRepeat : THREE.LoopOnce;
|
|
193
|
+
action.clampWhenFinished = true;
|
|
194
|
+
action.setEffectiveWeight(1);
|
|
195
|
+
action.play();
|
|
196
|
+
return [clip.name, action];
|
|
197
|
+
}));
|
|
198
|
+
return { entries, mixer, actions: proceduralActions };
|
|
199
|
+
}, [gltf.scene, gltf.animations]);
|
|
197
200
|
const camera = useThree((state) => state.camera);
|
|
198
|
-
const raycaster = useThree((state) => state.raycaster);
|
|
199
201
|
const controls = useThree((state) => state.controls);
|
|
200
202
|
const walkthroughMode = useViewer((s) => s.walkthroughMode);
|
|
201
203
|
const textures = useViewer((s) => s.textures);
|
|
@@ -211,7 +213,7 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
211
213
|
return;
|
|
212
214
|
object.traverse((child) => {
|
|
213
215
|
const mesh = child;
|
|
214
|
-
if (!mesh.isMesh
|
|
216
|
+
if (!mesh.isMesh)
|
|
215
217
|
return;
|
|
216
218
|
const ud = mesh.userData;
|
|
217
219
|
if (!ud.__bakedMaterial)
|
|
@@ -312,15 +314,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
312
314
|
}
|
|
313
315
|
if (extras.kind === 'zone' && extras.polygon && extras.polygon.length >= 3) {
|
|
314
316
|
const polygon = extras.polygon;
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
polygon.reduce((sum, [, z]) => sum + z, 0) / polygon.length,
|
|
318
|
-
];
|
|
317
|
+
const holes = extras.holes ?? [];
|
|
318
|
+
const centroid = polygonInteriorPoint({ polygon, holes });
|
|
319
319
|
zoneList.push({
|
|
320
320
|
id: extras.pascalId,
|
|
321
321
|
node: object,
|
|
322
322
|
levelId: findAncestorLevelId(object),
|
|
323
323
|
polygon,
|
|
324
|
+
holes,
|
|
324
325
|
label: extras.label ?? extras.pascalId,
|
|
325
326
|
color: extras.color ?? '#3b82f6',
|
|
326
327
|
centroid,
|
|
@@ -338,9 +339,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
338
339
|
levelsWithZones: new Set(zoneList.map((zone) => zone.levelId)),
|
|
339
340
|
};
|
|
340
341
|
}, [gltf.scene]);
|
|
341
|
-
const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]);
|
|
342
342
|
// Level pascalIds bottom-to-top, for the interactive light pool's level factor.
|
|
343
343
|
const levelOrder = useMemo(() => levels.map((entry) => entry.id), [levels]);
|
|
344
|
+
// Loop clips no other controller plays (a plugin kind's mechanism) run on
|
|
345
|
+
// click and E. As in the editor they start stopped and never persist.
|
|
346
|
+
const loopMechanisms = useMemo(() => bakedLoopMechanisms(identity, new Set([
|
|
347
|
+
...proceduralPlayback.entries.keys(),
|
|
348
|
+
...(interactiveItems ?? []).map((item) => item.pascalId),
|
|
349
|
+
])), [identity, proceduralPlayback, interactiveItems]);
|
|
344
350
|
// The dollhouse hides ceilings/roof — but only their OWN geometry. Items hosted
|
|
345
351
|
// on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding
|
|
346
352
|
// the whole occluder node would hide them too, so collect just the occluder's
|
|
@@ -363,71 +369,20 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
363
369
|
}
|
|
364
370
|
return meshes;
|
|
365
371
|
}, [occluders]);
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// footprint for empty zone nodes, the level, or the whole building). Mirrors
|
|
369
|
-
// the parametric viewer's selection framing so the GLB path feels identical.
|
|
370
|
-
const focusLevelId = useViewer((s) => s.selection.levelId);
|
|
371
|
-
const focusZoneId = useViewer((s) => s.selection.zoneId);
|
|
372
|
-
const focusSelectedId = useViewer((s) => s.selection.selectedIds[0] ?? null);
|
|
372
|
+
// Open on the building: its saved view, else a fit of its bounds. Later moves
|
|
373
|
+
// belong to the host's navigation (`camera-controls:frame`), never to a click.
|
|
373
374
|
useEffect(() => {
|
|
374
375
|
if (!controls)
|
|
375
376
|
return;
|
|
376
|
-
const
|
|
377
|
+
const bookmark = rootNode?.userData?.camera;
|
|
378
|
+
if (bookmark) {
|
|
377
379
|
const { position: p, target: t } = bookmark;
|
|
378
380
|
controls.setLookAt(p[0], p[1], p[2], t[0], t[1], t[2], true);
|
|
379
381
|
controls.normalizeRotations?.();
|
|
380
|
-
};
|
|
381
|
-
// Item selection happens inside a room, where we're already at a good angle:
|
|
382
|
-
// fly to the item's own bookmark if it has one, otherwise just pan to it
|
|
383
|
-
// (keep the current orbit angle + distance) rather than reframing the camera.
|
|
384
|
-
if (focusSelectedId) {
|
|
385
|
-
const object = identity.get(focusSelectedId);
|
|
386
|
-
if (!object)
|
|
387
|
-
return;
|
|
388
|
-
const itemBookmark = object.userData.camera;
|
|
389
|
-
if (itemBookmark) {
|
|
390
|
-
flyToBookmark(itemBookmark);
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
_camBox.makeEmpty();
|
|
394
|
-
_camBox.setFromObject(object);
|
|
395
|
-
if (_camBox.isEmpty())
|
|
396
|
-
return;
|
|
397
|
-
_camBox.getCenter(_camCenter);
|
|
398
|
-
controls.moveTo?.(_camCenter.x, _camCenter.y, _camCenter.z, true);
|
|
399
382
|
return;
|
|
400
383
|
}
|
|
401
|
-
let bookmarkNode = null;
|
|
402
384
|
_camBox.makeEmpty();
|
|
403
|
-
|
|
404
|
-
const zone = zoneById.get(focusZoneId);
|
|
405
|
-
if (!zone)
|
|
406
|
-
return;
|
|
407
|
-
bookmarkNode = zone.node;
|
|
408
|
-
// Zone identity nodes carry no mesh — bound the room from its polygon.
|
|
409
|
-
zone.node.updateWorldMatrix(true, false);
|
|
410
|
-
for (const [x, z] of zone.polygon) {
|
|
411
|
-
_camBox.expandByPoint(_camPoint.set(x, 0, z).applyMatrix4(zone.node.matrixWorld));
|
|
412
|
-
_camBox.expandByPoint(_camPoint.set(x, ZONE_WALL_HEIGHT, z).applyMatrix4(zone.node.matrixWorld));
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
else if (focusLevelId) {
|
|
416
|
-
const object = identity.get(focusLevelId);
|
|
417
|
-
if (!object)
|
|
418
|
-
return;
|
|
419
|
-
bookmarkNode = object;
|
|
420
|
-
_camBox.setFromObject(object);
|
|
421
|
-
}
|
|
422
|
-
else {
|
|
423
|
-
bookmarkNode = rootNode;
|
|
424
|
-
_camBox.setFromObject(rootNode ?? gltf.scene);
|
|
425
|
-
}
|
|
426
|
-
const bookmark = bookmarkNode?.userData?.camera;
|
|
427
|
-
if (bookmark) {
|
|
428
|
-
flyToBookmark(bookmark);
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
385
|
+
_camBox.setFromObject(rootNode ?? gltf.scene);
|
|
431
386
|
if (_camBox.isEmpty())
|
|
432
387
|
return;
|
|
433
388
|
_camBox.getCenter(_camCenter);
|
|
@@ -435,47 +390,21 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
435
390
|
const distance = Math.max(Math.max(_camSize.x, _camSize.y, _camSize.z) * 2, 15);
|
|
436
391
|
controls.setLookAt(_camCenter.x + distance * 0.7, _camCenter.y + distance * 0.5, _camCenter.z + distance * 0.7, _camCenter.x, _camCenter.y, _camCenter.z, true);
|
|
437
392
|
controls.normalizeRotations?.();
|
|
438
|
-
}, [
|
|
439
|
-
controls,
|
|
440
|
-
focusSelectedId,
|
|
441
|
-
focusZoneId,
|
|
442
|
-
focusLevelId,
|
|
443
|
-
identity,
|
|
444
|
-
zoneById,
|
|
445
|
-
rootNode,
|
|
446
|
-
gltf.scene,
|
|
447
|
-
]);
|
|
448
|
-
useEffect(() => {
|
|
449
|
-
const cameraMask = camera.layers.mask;
|
|
450
|
-
const raycasterMask = raycaster.layers.mask;
|
|
451
|
-
camera.layers.enable(ZONE_LAYER);
|
|
452
|
-
raycaster.layers.disable(ZONE_LAYER);
|
|
453
|
-
return () => {
|
|
454
|
-
camera.layers.mask = cameraMask;
|
|
455
|
-
raycaster.layers.mask = raycasterMask;
|
|
456
|
-
};
|
|
457
|
-
}, [camera, raycaster]);
|
|
393
|
+
}, [controls, rootNode, gltf.scene]);
|
|
458
394
|
useEffect(() => {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
onIdentityChange?.({});
|
|
469
|
-
};
|
|
470
|
-
}, [levels, identity, onLevelsChange, onIdentityChange]);
|
|
471
|
-
// Apply the editor's level modes to the baked floors each frame. Walkthrough
|
|
472
|
-
// always shows the full stacked building (you're standing inside it) — and the
|
|
473
|
-
// first-person collider is built from the visible meshes, so a hidden solo
|
|
474
|
-
// floor would otherwise drop the player through the world.
|
|
395
|
+
onObjectsChange?.(identity);
|
|
396
|
+
return () => onObjectsChange?.(new Map());
|
|
397
|
+
}, [identity, onObjectsChange]);
|
|
398
|
+
// Apply the viewer's level display to the baked floors each frame, by the
|
|
399
|
+
// parametric LevelSystem's rule (solo, and the levels above the current one
|
|
400
|
+
// hidden when the host asks). Walkthrough always shows the full stacked
|
|
401
|
+
// building (you're standing inside it) — and the first-person collider is
|
|
402
|
+
// built from the visible meshes, so a hidden floor would otherwise drop the
|
|
403
|
+
// player through the world.
|
|
475
404
|
useFrame((_, delta) => {
|
|
476
405
|
if (levels.length === 0)
|
|
477
406
|
return;
|
|
478
|
-
const { levelMode, selection, walkthroughMode } = useViewer.getState();
|
|
407
|
+
const { levelMode, hideLevelsAboveSelection, selection, walkthroughMode } = useViewer.getState();
|
|
479
408
|
const selectedLevel = selection.levelId;
|
|
480
409
|
const selectedIdx = selectedLevel ? levels.findIndex((l) => l.id === selectedLevel) : -1;
|
|
481
410
|
levels.forEach(({ id, node, baseY }, index) => {
|
|
@@ -486,77 +415,72 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
486
415
|
node.position.y = walkthroughMode
|
|
487
416
|
? targetY
|
|
488
417
|
: lerp(node.position.y, targetY, Math.min(1, delta * 12));
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
418
|
+
const { visible, shadowOnly } = walkthroughMode
|
|
419
|
+
? { visible: true, shadowOnly: false }
|
|
420
|
+
: resolveLevelVisibility({
|
|
421
|
+
levelMode,
|
|
422
|
+
hideAbove: hideLevelsAboveSelection,
|
|
423
|
+
hasSelectedLevel: selectedIdx >= 0,
|
|
424
|
+
isSelected: id === selectedLevel,
|
|
425
|
+
index,
|
|
426
|
+
selectedIndex: selectedIdx >= 0 ? selectedIdx : undefined,
|
|
427
|
+
nodeVisible: true,
|
|
428
|
+
revealing: false,
|
|
429
|
+
});
|
|
430
|
+
if (shadowOnly)
|
|
494
431
|
applyShadowOnly(node);
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
else {
|
|
432
|
+
else
|
|
498
433
|
clearShadowOnly(node);
|
|
499
|
-
|
|
500
|
-
}
|
|
434
|
+
node.visible = visible;
|
|
501
435
|
});
|
|
502
436
|
}, 5);
|
|
503
|
-
const zoneFills = useRef([]);
|
|
504
437
|
useEffect(() => {
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (i === 0)
|
|
510
|
-
shape.moveTo(x, -z);
|
|
511
|
-
else
|
|
512
|
-
shape.lineTo(x, -z);
|
|
513
|
-
});
|
|
514
|
-
shape.closePath();
|
|
515
|
-
const floorMaterial = createZoneFloorMaterial(entry.color);
|
|
516
|
-
const floor = new THREE.Mesh(new THREE.ShapeGeometry(shape), floorMaterial);
|
|
517
|
-
floor.rotation.x = -Math.PI / 2;
|
|
518
|
-
floor.position.y = 0.02;
|
|
519
|
-
const wallMaterial = createZoneWallMaterial(entry.color);
|
|
520
|
-
const walls = new THREE.Mesh(createZoneWallGeometry(entry.polygon), wallMaterial);
|
|
521
|
-
const meshes = [floor, walls];
|
|
522
|
-
for (const mesh of meshes) {
|
|
523
|
-
mesh.visible = false;
|
|
524
|
-
mesh.layers.set(ZONE_LAYER);
|
|
525
|
-
// Visual helpers only — never participate in picking. Hover/selection
|
|
526
|
-
// resolves against the real building geometry + point-in-polygon, so the
|
|
527
|
-
// tall wall helpers can't occlude items or fight at shared boundaries.
|
|
528
|
-
mesh.raycast = NO_RAYCAST;
|
|
529
|
-
entry.node.add(mesh);
|
|
530
|
-
}
|
|
531
|
-
built.push({
|
|
532
|
-
id: entry.id,
|
|
533
|
-
levelId: entry.levelId,
|
|
534
|
-
meshes,
|
|
535
|
-
uniforms: [
|
|
536
|
-
floorMaterial.userData.uOpacity,
|
|
537
|
-
wallMaterial.userData.uOpacity,
|
|
538
|
-
],
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
zoneFills.current = built;
|
|
438
|
+
for (const [nodeId, entry] of proceduralPlayback.entries)
|
|
439
|
+
useInteractive
|
|
440
|
+
.getState()
|
|
441
|
+
.initProcedural(nodeId, [...new Set(entry.controller.motions.map((motion) => motion.partId))], entry.spinParts);
|
|
542
442
|
return () => {
|
|
543
|
-
for (const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
443
|
+
for (const nodeId of proceduralPlayback.entries.keys())
|
|
444
|
+
useInteractive.getState().removeProcedural(nodeId);
|
|
445
|
+
proceduralPlayback.mixer.stopAllAction();
|
|
446
|
+
};
|
|
447
|
+
}, [proceduralPlayback]);
|
|
448
|
+
useFrame((_, delta) => {
|
|
449
|
+
for (const [nodeId, entry] of proceduralPlayback.entries) {
|
|
450
|
+
const command = useInteractive.getState().procedural[nodeId]?.motionCommand;
|
|
451
|
+
if (command && command.sequence > entry.sequence) {
|
|
452
|
+
entry.controller.command(command);
|
|
453
|
+
entry.sequence = command.sequence;
|
|
454
|
+
}
|
|
455
|
+
const frame = entry.controller.tick(delta);
|
|
456
|
+
for (const [clipName, binding] of entry.clips) {
|
|
457
|
+
const action = proceduralPlayback.actions.get(clipName);
|
|
458
|
+
if (!action)
|
|
459
|
+
continue;
|
|
460
|
+
if (binding.kind === 'finite')
|
|
461
|
+
action.time = frame.times[binding.partId] ?? 0;
|
|
462
|
+
else {
|
|
463
|
+
const motion = entry.controller.motions.find((item) => item.id === binding.groupId);
|
|
464
|
+
if (motion)
|
|
465
|
+
action.time =
|
|
466
|
+
((frame.spins[motion.id]?.phase ?? 0) / (2 * Math.PI)) * action.getClip().duration;
|
|
548
467
|
}
|
|
549
468
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
},
|
|
469
|
+
}
|
|
470
|
+
proceduralPlayback.mixer.update(0);
|
|
471
|
+
}, -1);
|
|
553
472
|
useEffect(() => {
|
|
473
|
+
const scriptedPrefixes = (interactiveItems ?? [])
|
|
474
|
+
.filter((item) => item.scripted)
|
|
475
|
+
.map((item) => `${item.pascalId}: `);
|
|
554
476
|
for (const [name, action] of Object.entries(actions)) {
|
|
555
477
|
if (!action)
|
|
556
478
|
continue;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
//
|
|
479
|
+
if (proceduralPlayback.actions.has(name))
|
|
480
|
+
continue;
|
|
481
|
+
// ScriptedClips owns each authored clip's playback mode.
|
|
482
|
+
if (scriptedPrefixes.some((prefix) => name.startsWith(prefix)))
|
|
483
|
+
continue;
|
|
560
484
|
if (name.endsWith(': loop')) {
|
|
561
485
|
action.loop = THREE.LoopRepeat;
|
|
562
486
|
action.clampWhenFinished = false;
|
|
@@ -566,11 +490,87 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
566
490
|
action.clampWhenFinished = true;
|
|
567
491
|
}
|
|
568
492
|
}
|
|
569
|
-
}, [actions]);
|
|
493
|
+
}, [actions, proceduralPlayback, interactiveItems]);
|
|
494
|
+
useEffect(() => {
|
|
495
|
+
if (loopMechanisms.size === 0)
|
|
496
|
+
return;
|
|
497
|
+
const apply = (running) => {
|
|
498
|
+
for (const [id, clips] of loopMechanisms) {
|
|
499
|
+
for (const name of clips) {
|
|
500
|
+
const action = actions[name];
|
|
501
|
+
if (!action)
|
|
502
|
+
continue;
|
|
503
|
+
if (running[id]) {
|
|
504
|
+
action.enabled = true;
|
|
505
|
+
action.paused = false;
|
|
506
|
+
if (!action.isRunning())
|
|
507
|
+
action.play();
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
action.stop();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
apply(useInteractive.getState().mechanisms);
|
|
516
|
+
const unsubscribe = useInteractive.subscribe((state, previous) => {
|
|
517
|
+
if (state.mechanisms !== previous.mechanisms)
|
|
518
|
+
apply(state.mechanisms);
|
|
519
|
+
});
|
|
520
|
+
return () => {
|
|
521
|
+
unsubscribe();
|
|
522
|
+
for (const id of loopMechanisms.keys())
|
|
523
|
+
useInteractive.getState().removeMechanism(id);
|
|
524
|
+
};
|
|
525
|
+
}, [actions, loopMechanisms]);
|
|
526
|
+
// Items the walkthrough operates through their toggles; others fall through to loop clips.
|
|
527
|
+
const toggleableItem = useCallback((pascalId) => interactiveItems?.find((item) => item.pascalId === pascalId &&
|
|
528
|
+
item.interactive.controls.some((control) => control.kind === 'toggle')), [interactiveItems]);
|
|
529
|
+
// A click opens and closes an item with an `open` clip (an authored object,
|
|
530
|
+
// a scripted window or door), like a door.
|
|
531
|
+
const toggleOpenControl = useCallback((identityNode) => {
|
|
532
|
+
const item = toggleableItem(identityNode.userData.pascalId);
|
|
533
|
+
if (!item || itemInteraction(item.interactive).kind !== 'open')
|
|
534
|
+
return false;
|
|
535
|
+
operateItem(item.pascalId, item.interactive);
|
|
536
|
+
return true;
|
|
537
|
+
}, [toggleableItem]);
|
|
538
|
+
const toggleLoopMechanism = useCallback((identityNode) => {
|
|
539
|
+
const id = identityNode.userData.pascalId;
|
|
540
|
+
if (!(id && loopMechanisms.has(id)))
|
|
541
|
+
return false;
|
|
542
|
+
const state = useInteractive.getState();
|
|
543
|
+
state.setMechanism(id, !state.mechanisms[id]);
|
|
544
|
+
return true;
|
|
545
|
+
}, [loopMechanisms]);
|
|
570
546
|
const openIds = useRef(new Set());
|
|
547
|
+
const toggleProcedural = useCallback((hit, identityNode) => {
|
|
548
|
+
const extras = identityNode.userData;
|
|
549
|
+
if (extras.kind !== 'procedural-item')
|
|
550
|
+
return false;
|
|
551
|
+
const part = findProceduralMotionAncestor(hit);
|
|
552
|
+
if (part?.clip) {
|
|
553
|
+
useInteractive.getState().toggleProceduralPart(part.nodeId, part.partId);
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
const nodeId = extras.pascalId;
|
|
557
|
+
const entry = proceduralPlayback.entries.get(nodeId);
|
|
558
|
+
if (!entry)
|
|
559
|
+
return false;
|
|
560
|
+
const parts = [...new Set(entry.controller.motions.map((motion) => motion.partId))];
|
|
561
|
+
if (parts.length === 0) {
|
|
562
|
+
useInteractive.getState().toggleProceduralLights(nodeId);
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
const active = useInteractive.getState().procedural[nodeId]?.parts;
|
|
566
|
+
useInteractive
|
|
567
|
+
.getState()
|
|
568
|
+
.setProceduralParts(nodeId, parts, !parts.some((partId) => active?.[partId]));
|
|
569
|
+
return true;
|
|
570
|
+
}, [proceduralPlayback]);
|
|
571
571
|
const toggleOpenable = useCallback((node) => {
|
|
572
572
|
const extras = node.userData;
|
|
573
|
-
const clipName = extras.clips?.
|
|
573
|
+
const clipName = extras.clips?.find((name) => name.endsWith(': open'));
|
|
574
574
|
if (!extras.openable || !clipName)
|
|
575
575
|
return;
|
|
576
576
|
const action = actions[clipName];
|
|
@@ -598,100 +598,24 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
598
598
|
continue;
|
|
599
599
|
_local.copy(worldPoint);
|
|
600
600
|
entry.node.worldToLocal(_local);
|
|
601
|
-
if (
|
|
601
|
+
if (pointInZone(_local.x, _local.z, entry))
|
|
602
602
|
return entry;
|
|
603
603
|
}
|
|
604
604
|
return null;
|
|
605
605
|
}, [zoneEntries]);
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
const zoneAtRay = useCallback((ray, levelId) => {
|
|
611
|
-
const levelNode = identity.get(levelId);
|
|
612
|
-
const floorY = levelNode ? levelNode.getWorldPosition(_floorPlanePoint).y : 0;
|
|
613
|
-
_floorPlane.setFromNormalAndCoplanarPoint(_up, _floorPlanePoint.set(0, floorY, 0));
|
|
614
|
-
if (!ray.intersectPlane(_floorPlane, _floorHit))
|
|
615
|
-
return null;
|
|
616
|
-
return zoneAtPoint(_floorHit, levelId);
|
|
617
|
-
}, [identity, zoneAtPoint]);
|
|
618
|
-
// Resolve a pointer ray to the unit the current drill depth acts on:
|
|
619
|
-
// - building view → the floor the hit object belongs to
|
|
620
|
-
// - level view → the room the cursor points at on the floor
|
|
621
|
-
// - zone view → the first hit node whose hit/footprint is inside the room
|
|
622
|
-
const resolveTarget = useCallback((hits, ray) => {
|
|
623
|
-
const firstNode = hits.length > 0 ? findIdentityAncestor(hits[0].object) : null;
|
|
624
|
-
const toTarget = (object, tid) => {
|
|
625
|
-
const e = object.userData;
|
|
626
|
-
return { object, id: tid, kind: e.kind ?? 'node', label: e.label ?? tid };
|
|
627
|
-
};
|
|
628
|
-
const { selection } = useViewer.getState();
|
|
629
|
-
// Building view → drill to the floor the hit object belongs to.
|
|
630
|
-
if (!selection.levelId) {
|
|
631
|
-
if (!firstNode)
|
|
632
|
-
return null;
|
|
633
|
-
const extras = firstNode.userData;
|
|
634
|
-
const levelId = extras.kind === 'level' ? extras.pascalId : findAncestorLevelId(firstNode);
|
|
635
|
-
const levelObject = levelId ? identity.get(levelId) : undefined;
|
|
636
|
-
return levelObject && levelId ? toTarget(levelObject, levelId) : null;
|
|
637
|
-
}
|
|
638
|
-
// Level view → the room the cursor is over (floor-plane intersection).
|
|
639
|
-
if (!selection.zoneId) {
|
|
640
|
-
const zone = zoneAtRay(ray, selection.levelId);
|
|
641
|
-
return zone ? toTarget(zone.node, zone.id) : null;
|
|
642
|
-
}
|
|
643
|
-
// Zone view → scan through all R3F intersections so room helpers or slabs
|
|
644
|
-
// can't hide a selectable item/structure behind the first hit.
|
|
645
|
-
const activeZone = zoneById.get(selection.zoneId);
|
|
646
|
-
if (!activeZone)
|
|
647
|
-
return null;
|
|
648
|
-
const seen = new Set();
|
|
649
|
-
for (const hit of hits) {
|
|
650
|
-
const node = findIdentityAncestor(hit.object);
|
|
651
|
-
if (!node)
|
|
652
|
-
continue;
|
|
653
|
-
const extras = node.userData;
|
|
654
|
-
const id = extras.pascalId;
|
|
655
|
-
if (!id || seen.has(id))
|
|
656
|
-
continue;
|
|
657
|
-
seen.add(id);
|
|
658
|
-
const kind = extras.kind ?? 'node';
|
|
659
|
-
if (kind === 'site' || kind === 'building' || kind === 'level' || kind === 'zone') {
|
|
660
|
-
continue;
|
|
661
|
-
}
|
|
662
|
-
const hitLevel = findAncestorLevelId(node);
|
|
663
|
-
if (hitLevel !== selection.levelId)
|
|
664
|
-
continue;
|
|
665
|
-
if (hit.point && worldPointInZoneFootprint(hit.point, activeZone)) {
|
|
666
|
-
return toTarget(node, id);
|
|
667
|
-
}
|
|
668
|
-
if (objectFootprintTouchesZone(node, activeZone)) {
|
|
669
|
-
return toTarget(node, id);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
return null;
|
|
673
|
-
}, [identity, zoneAtRay, zoneById]);
|
|
674
|
-
// DOM nodes for each zone's floating room label, plus a group whose transform
|
|
675
|
-
// tracks the zone node so the label rides level stacking.
|
|
676
|
-
const labelGroups = useRef(new Map());
|
|
677
|
-
const labelDivs = useRef(new Map());
|
|
678
|
-
// Per-frame: fade a level's rooms in/out (hidden once a zone is entered, like
|
|
679
|
-
// the editor) with a brightness bump on the hovered room, keep room labels
|
|
680
|
-
// positioned + faded with them, and sync the outline post-FX from the shared
|
|
681
|
-
// selection + local hover.
|
|
682
|
-
const hoveredTarget = useRef(null);
|
|
683
|
-
useFrame((_, delta) => {
|
|
606
|
+
// Per-frame: hide the focused floor's ceilings and roof when it has rooms
|
|
607
|
+
// (dollhouse), and sync the outline post-FX from the shared selection and
|
|
608
|
+
// hover. Rooms highlight through the host's room layer, not the outline.
|
|
609
|
+
useFrame(() => {
|
|
684
610
|
const state = useViewer.getState();
|
|
685
611
|
const { selection, outliner } = state;
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
// Walkthrough is a first-person tour: no zone tints, no dollhouse cutaway,
|
|
689
|
-
// no selection outline — you're standing inside the real building.
|
|
612
|
+
// Walkthrough is a first-person tour: no dollhouse cutaway, no selection
|
|
613
|
+
// outline — you're standing inside the real building.
|
|
690
614
|
const walk = state.walkthroughMode;
|
|
691
|
-
// Dollhouse: hide ceilings + roof so the rooms
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
615
|
+
// Dollhouse: hide ceilings + roof so the rooms are visible from above and
|
|
616
|
+
// the ray reaches their contents — but only when the focused level actually
|
|
617
|
+
// has rooms. Focusing a zone-less floor keeps the building intact
|
|
618
|
+
// (otherwise its roof would just vanish with nothing to show).
|
|
695
619
|
const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId);
|
|
696
620
|
// Shadow-caster-only: hidden roof/ceiling meshes keep casting sun shadows
|
|
697
621
|
// so interiors show window light patches instead of uniform sun flood.
|
|
@@ -701,47 +625,23 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
701
625
|
else
|
|
702
626
|
clearShadowOnly(mesh);
|
|
703
627
|
}
|
|
704
|
-
for (const { id, levelId, meshes, uniforms } of zoneFills.current) {
|
|
705
|
-
const show = !walk && selection.levelId != null && levelId === selection.levelId && !selection.zoneId;
|
|
706
|
-
const target = !show ? 0 : id === hoveredZoneId ? 1 : 0.65;
|
|
707
|
-
let visible = false;
|
|
708
|
-
for (const u of uniforms) {
|
|
709
|
-
u.value = lerp(u.value, target, t);
|
|
710
|
-
if (u.value > 0.01)
|
|
711
|
-
visible = true;
|
|
712
|
-
}
|
|
713
|
-
for (const mesh of meshes)
|
|
714
|
-
mesh.visible = visible;
|
|
715
|
-
const group = labelGroups.current.get(id);
|
|
716
|
-
const zoneNode = identity.get(id);
|
|
717
|
-
if (group && zoneNode) {
|
|
718
|
-
group.matrixAutoUpdate = false;
|
|
719
|
-
group.matrix.copy(zoneNode.matrixWorld);
|
|
720
|
-
group.visible = visible;
|
|
721
|
-
}
|
|
722
|
-
const div = labelDivs.current.get(id);
|
|
723
|
-
if (div) {
|
|
724
|
-
div.style.opacity = show ? '1' : '0';
|
|
725
|
-
// Small by default, smoothly zooming up when its room is hovered.
|
|
726
|
-
div.style.transform = `scale(${id === hoveredZoneId ? 1 : 0.82})`;
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
628
|
outliner.selectedObjects.length = 0;
|
|
730
629
|
outliner.hoveredObjects.length = 0;
|
|
731
630
|
if (walk)
|
|
732
631
|
return;
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
outliner.
|
|
632
|
+
for (const id of selection.selectedIds) {
|
|
633
|
+
const object = identity.get(id);
|
|
634
|
+
if (object)
|
|
635
|
+
outliner.selectedObjects.push(object);
|
|
636
|
+
}
|
|
637
|
+
const hovered = state.hoveredId ? identity.get(state.hoveredId) : undefined;
|
|
638
|
+
if (hovered &&
|
|
639
|
+
hovered.userData.kind !== 'zone' &&
|
|
640
|
+
!outliner.selectedObjects.includes(hovered)) {
|
|
641
|
+
outliner.hoveredObjects.push(hovered);
|
|
742
642
|
}
|
|
743
643
|
});
|
|
744
|
-
// ── Walkthrough: first-person HUD +
|
|
644
|
+
// ── Walkthrough: first-person HUD + interaction ────────────────────────────
|
|
745
645
|
const walkDoorRef = useRef(null);
|
|
746
646
|
const lastWalkKey = useRef(null);
|
|
747
647
|
// Each frame in walkthrough, report the floor + room the camera stands in and
|
|
@@ -769,11 +669,51 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
769
669
|
if (hit) {
|
|
770
670
|
const node = findIdentityAncestor(hit.object);
|
|
771
671
|
const extras = node?.userData;
|
|
772
|
-
|
|
773
|
-
|
|
672
|
+
const lightOnly = extras?.pascalId &&
|
|
673
|
+
interactiveItems?.some((item) => item.pascalId === extras.pascalId &&
|
|
674
|
+
item.procedural?.lights.length &&
|
|
675
|
+
operableParts(item.procedural.recipe ?? { parts: item.procedural.parts }).length === 0);
|
|
676
|
+
const item = toggleableItem(extras?.pascalId);
|
|
677
|
+
if (node && extras?.kind === 'procedural-item' && (extras.clips?.length || lightOnly)) {
|
|
678
|
+
doorNode = { hit: hit.object, node };
|
|
679
|
+
const part = findProceduralMotionAncestor(hit.object);
|
|
680
|
+
const state = useInteractive.getState().procedural[extras.pascalId];
|
|
681
|
+
const isOpen = lightOnly
|
|
682
|
+
? (state?.lightsOn ?? useInteractive.getState().lampDefault)
|
|
683
|
+
: part
|
|
684
|
+
? Boolean(state?.parts[part.partId])
|
|
685
|
+
: Object.values(state?.parts ?? {}).some(Boolean);
|
|
686
|
+
const clips = part?.clip ? [part.clip] : extras.clips;
|
|
687
|
+
const isSpin = part
|
|
688
|
+
? part.kind === 'spin'
|
|
689
|
+
: (clips?.every((clip) => clip.endsWith(': loop')) ?? false);
|
|
690
|
+
const label = lightOnly
|
|
691
|
+
? (extras.label ?? 'Lights')
|
|
692
|
+
: (part?.partId.replaceAll('_', ' ') ?? extras.label ?? 'Item');
|
|
693
|
+
door = {
|
|
694
|
+
label,
|
|
695
|
+
isOpen,
|
|
696
|
+
verb: lightOnly || isSpin ? (isOpen ? 'turn off' : 'turn on') : isOpen ? 'close' : 'open',
|
|
697
|
+
};
|
|
698
|
+
doorId = `${extras.pascalId}:${part?.partId ?? 'all'}`;
|
|
699
|
+
}
|
|
700
|
+
else if (node && item) {
|
|
701
|
+
doorNode = { hit: hit.object, node };
|
|
702
|
+
doorId = item.pascalId;
|
|
703
|
+
const prompt = itemPrompt(item.pascalId, item.label, item.interactive);
|
|
704
|
+
door = { label: prompt.label, isOpen: prompt.isOn, verb: prompt.verb };
|
|
705
|
+
}
|
|
706
|
+
else if (node && extras?.openable && extras.clips?.length) {
|
|
707
|
+
doorNode = { hit: hit.object, node };
|
|
774
708
|
doorId = extras.pascalId;
|
|
775
709
|
door = { label: extras.label ?? 'Door', isOpen: openIds.current.has(doorId) };
|
|
776
710
|
}
|
|
711
|
+
else if (node && extras?.pascalId && loopMechanisms.has(extras.pascalId)) {
|
|
712
|
+
doorNode = { hit: hit.object, node };
|
|
713
|
+
doorId = extras.pascalId;
|
|
714
|
+
const isOpen = Boolean(useInteractive.getState().mechanisms[doorId]);
|
|
715
|
+
door = { label: extras.label ?? 'Item', isOpen, verb: isOpen ? 'turn off' : 'turn on' };
|
|
716
|
+
}
|
|
777
717
|
}
|
|
778
718
|
walkDoorRef.current = doorNode;
|
|
779
719
|
const key = `${floor?.id ?? ''}|${zone?.id ?? ''}|${door ? `${doorId}:${door.isOpen}` : ''}`;
|
|
@@ -785,9 +725,17 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
785
725
|
// E or click activates the openable in view. The click also re-locks the
|
|
786
726
|
// pointer through the walkthrough controller; no selection happens.
|
|
787
727
|
const activateWalkDoor = useCallback(() => {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
728
|
+
const target = walkDoorRef.current;
|
|
729
|
+
if (!target)
|
|
730
|
+
return;
|
|
731
|
+
const item = toggleableItem(target.node.userData.pascalId);
|
|
732
|
+
if (item) {
|
|
733
|
+
operateItem(item.pascalId, item.interactive);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (!(toggleProcedural(target.hit, target.node) || toggleLoopMechanism(target.node)))
|
|
737
|
+
toggleOpenable(target.node);
|
|
738
|
+
}, [toggleableItem, toggleLoopMechanism, toggleOpenable, toggleProcedural]);
|
|
791
739
|
useEffect(() => {
|
|
792
740
|
if (!walkthroughMode)
|
|
793
741
|
return;
|
|
@@ -814,83 +762,57 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
814
762
|
const lastHover = useRef(null);
|
|
815
763
|
const handlePointerMove = useCallback((event) => {
|
|
816
764
|
event.stopPropagation();
|
|
817
|
-
if (walkthroughMode)
|
|
765
|
+
if (walkthroughMode || !onPick)
|
|
818
766
|
return;
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}, [resolveTarget, onHoverChange, walkthroughMode]);
|
|
767
|
+
const pick = resolveGlbPick(event.intersections, identity);
|
|
768
|
+
document.body.style.cursor = pick ? 'pointer' : 'auto';
|
|
769
|
+
const key = pick ? `${pick.nodeId}:${pick.point?.join(',') ?? ''}` : null;
|
|
770
|
+
if (key === lastHover.current)
|
|
771
|
+
return;
|
|
772
|
+
lastHover.current = key;
|
|
773
|
+
onPick({ type: 'hover', pick, modifiers: modifiersOf(event.nativeEvent) });
|
|
774
|
+
}, [identity, onPick, walkthroughMode]);
|
|
828
775
|
const handlePointerOut = useCallback(() => {
|
|
829
|
-
hoveredTarget.current = null;
|
|
830
776
|
document.body.style.cursor = 'auto';
|
|
831
|
-
if (lastHover.current
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
}
|
|
835
|
-
}, [
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
// guard clears deeper selections automatically when a parent changes.
|
|
777
|
+
if (lastHover.current === null)
|
|
778
|
+
return;
|
|
779
|
+
lastHover.current = null;
|
|
780
|
+
onPick?.({ type: 'hover', pick: null, modifiers: NO_MODIFIERS });
|
|
781
|
+
}, [onPick]);
|
|
782
|
+
// The host's rules decide the selection; an openable the click selects (a
|
|
783
|
+
// door, a window, an item with an `open` clip, a mechanism) also plays.
|
|
839
784
|
const handleClick = useCallback((event) => {
|
|
840
785
|
event.stopPropagation();
|
|
841
786
|
// Walkthrough handles its own door activation (E / canvas click) and never
|
|
842
|
-
// selects
|
|
843
|
-
if (walkthroughMode)
|
|
787
|
+
// selects.
|
|
788
|
+
if (walkthroughMode || !onPick)
|
|
844
789
|
return;
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
if (!selection.levelId) {
|
|
849
|
-
if (target) {
|
|
850
|
-
setLevelMode('solo');
|
|
851
|
-
setSelection({ levelId: target.id });
|
|
852
|
-
}
|
|
790
|
+
const pick = resolveGlbPick(event.intersections, identity);
|
|
791
|
+
onPick({ type: 'click', pick, modifiers: modifiersOf(event.nativeEvent) });
|
|
792
|
+
if (!pick || !useViewer.getState().selection.selectedIds.includes(pick.nodeId))
|
|
853
793
|
return;
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
// the building.
|
|
857
|
-
if (!selection.zoneId) {
|
|
858
|
-
if (target) {
|
|
859
|
-
setSelection({ zoneId: target.id });
|
|
860
|
-
}
|
|
861
|
-
else {
|
|
862
|
-
setLevelMode('stacked');
|
|
863
|
-
setSelection({ levelId: null });
|
|
864
|
-
}
|
|
794
|
+
const object = identity.get(pick.nodeId);
|
|
795
|
+
if (!object)
|
|
865
796
|
return;
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
797
|
+
if (!(toggleOpenControl(object) ||
|
|
798
|
+
toggleProcedural(pick.hitObject, object) ||
|
|
799
|
+
toggleLoopMechanism(object)))
|
|
800
|
+
toggleOpenable(object);
|
|
801
|
+
}, [
|
|
802
|
+
identity,
|
|
803
|
+
onPick,
|
|
804
|
+
toggleLoopMechanism,
|
|
805
|
+
toggleOpenControl,
|
|
806
|
+
toggleOpenable,
|
|
807
|
+
toggleProcedural,
|
|
808
|
+
walkthroughMode,
|
|
809
|
+
]);
|
|
810
|
+
// A click on empty space clears the room and the element (the floor stays).
|
|
811
|
+
const handlePointerMissed = useCallback((event) => {
|
|
812
|
+
if (useViewer.getState().walkthroughMode || !onPick)
|
|
881
813
|
return;
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
setSelection({ selectedIds: [] });
|
|
885
|
-
}
|
|
886
|
-
else if (selection.zoneId) {
|
|
887
|
-
setSelection({ zoneId: null });
|
|
888
|
-
}
|
|
889
|
-
else if (selection.levelId) {
|
|
890
|
-
setLevelMode('stacked');
|
|
891
|
-
setSelection({ levelId: null });
|
|
892
|
-
}
|
|
893
|
-
}, []);
|
|
814
|
+
onPick({ type: 'click', pick: null, modifiers: modifiersOf(event) });
|
|
815
|
+
}, [onPick]);
|
|
894
816
|
useEffect(() => () => {
|
|
895
817
|
const { outliner } = useViewer.getState();
|
|
896
818
|
outliner.selectedObjects.length = 0;
|
|
@@ -900,23 +822,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
900
822
|
for (const mesh of occluderOwnMeshes)
|
|
901
823
|
clearShadowOnly(mesh);
|
|
902
824
|
}, [occluderOwnMeshes]);
|
|
903
|
-
return (_jsxs("group", { ref: rootRef, children: [_jsx("primitive", { object: gltf.scene, onClick: handleClick, onPointerMissed: handlePointerMissed, onPointerMove: handlePointerMove, onPointerOut: handlePointerOut }), interactiveItems?.length ? (_jsx(GlbInteractive, { actions: actions, identity: identity, items: interactiveItems, levelOrder: levelOrder, zones: zoneEntries })) : null, referenceNodes?.length ? (_jsx(GlbReferenceNodes, { identity: identity, nodes: referenceNodes })) : null, replaceNodes?.length ? (_jsx(GlbReplaceInstances, { identity: identity, nodes: replaceNodes })) : null
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
}, style: {
|
|
914
|
-
color: 'white',
|
|
915
|
-
opacity: 0,
|
|
916
|
-
textShadow: `-1px -1px 0 ${zone.color}, 1px -1px 0 ${zone.color}, -1px 1px 0 ${zone.color}, 1px 1px 0 ${zone.color}`,
|
|
917
|
-
transform: 'scale(0.82)',
|
|
918
|
-
transformOrigin: 'center',
|
|
919
|
-
transition: 'opacity 0.3s ease-in-out, transform 0.2s ease-out',
|
|
920
|
-
whiteSpace: 'nowrap',
|
|
921
|
-
}, children: zone.label }) }) }, zone.id)))] }));
|
|
825
|
+
return (_jsxs("group", { ref: rootRef, children: [_jsx("primitive", { object: gltf.scene, onClick: handleClick, onPointerMissed: handlePointerMissed, onPointerMove: handlePointerMove, onPointerOut: handlePointerOut }), interactiveItems?.length ? (_jsx(GlbInteractive, { actions: actions, identity: identity, items: interactiveItems, levelOrder: levelOrder, zones: zoneEntries })) : null, referenceNodes?.length ? (_jsx(GlbReferenceNodes, { identity: identity, nodes: referenceNodes })) : null, replaceNodes?.length ? (_jsx(GlbReplaceInstances, { identity: identity, nodes: replaceNodes })) : null] }));
|
|
826
|
+
}
|
|
827
|
+
const NO_MODIFIERS = {
|
|
828
|
+
alt: false,
|
|
829
|
+
ctrl: false,
|
|
830
|
+
meta: false,
|
|
831
|
+
shift: false,
|
|
832
|
+
};
|
|
833
|
+
function modifiersOf(event) {
|
|
834
|
+
return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey };
|
|
922
835
|
}
|