@pascal-app/viewer 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +8 -3
- 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 +252 -85
- 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-scene.d.ts +45 -27
- package/dist/components/viewer/glb-scene.d.ts.map +1 -1
- package/dist/components/viewer/glb-scene.js +420 -506
- 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 +12 -1
- package/dist/components/viewer/index.d.ts +14 -0
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +84 -26
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +6 -1
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
- package/dist/components/viewer/perf-monitor.js +6 -0
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +8 -3
- package/dist/components/viewer/registered-systems.d.ts.map +1 -1
- package/dist/components/viewer/registered-systems.js +16 -2
- 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-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 +10 -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 +11 -1
- package/dist/index.d.ts +22 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -14
- package/dist/lib/asset-url.d.ts.map +1 -1
- package/dist/lib/asset-url.js +7 -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/hero-pose.d.ts +12 -2
- 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 +4 -2
- 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/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 +14 -7
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +31 -5
- 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 +8 -0
- package/dist/lib/pointer-events.d.ts.map +1 -1
- package/dist/lib/pointer-events.js +27 -4
- 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/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-visibility.d.ts +6 -0
- package/dist/lib/scene-visibility.d.ts.map +1 -1
- package/dist/lib/scene-visibility.js +15 -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/snapshot-pipeline.d.ts +32 -2
- package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
- package/dist/lib/snapshot-pipeline.js +215 -105
- package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -1
- package/dist/lib/terrain-perimeter-fill.js +22 -14
- 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 +11 -0
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +13 -6
- 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 +86 -6
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +55 -17
- 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/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +40 -15
- 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 +47 -52
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +3 -5
- package/dist/systems/level/level-utils.d.ts +7 -4
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +17 -6
- 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/roof-materials.d.ts +14 -2
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +54 -15
- package/dist/systems/roof/roof-system.d.ts +13 -3
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +188 -13
- 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 +7 -0
- package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout-cache.js +46 -17
- 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-materials.d.ts +9 -4
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +99 -152
- package/dist/systems/wall/wall-system.d.ts +28 -2
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +582 -143
- 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 +41 -22
- package/package.json +5 -5
- 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,20 +1,20 @@
|
|
|
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, 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
20
|
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
|
|
@@ -32,6 +32,8 @@ const ROLE_BY_KIND = {
|
|
|
32
32
|
door: 'joinery',
|
|
33
33
|
item: 'furnishing',
|
|
34
34
|
};
|
|
35
|
+
/** Kinds a pointer passes through to what lies behind (they frame, they don't get picked). */
|
|
36
|
+
const PASS_THROUGH_KINDS = new Set(['site', 'building', 'level', 'zone', 'ceiling', 'spawn']);
|
|
35
37
|
function findIdentityAncestor(object) {
|
|
36
38
|
let current = object;
|
|
37
39
|
while (current) {
|
|
@@ -41,6 +43,16 @@ function findIdentityAncestor(object) {
|
|
|
41
43
|
}
|
|
42
44
|
return null;
|
|
43
45
|
}
|
|
46
|
+
function findProceduralMotionAncestor(object) {
|
|
47
|
+
let current = object;
|
|
48
|
+
while (current) {
|
|
49
|
+
const motion = current.userData.proceduralMotion;
|
|
50
|
+
if (motion)
|
|
51
|
+
return motion;
|
|
52
|
+
current = current.parent;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
44
56
|
function findAncestorLevelId(object) {
|
|
45
57
|
let current = object.parent;
|
|
46
58
|
while (current) {
|
|
@@ -52,150 +64,142 @@ function findAncestorLevelId(object) {
|
|
|
52
64
|
return null;
|
|
53
65
|
}
|
|
54
66
|
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
67
|
const _camBox = new THREE.Box3();
|
|
63
68
|
const _camCenter = new THREE.Vector3();
|
|
64
69
|
const _camSize = new THREE.Vector3();
|
|
65
|
-
const _camPoint = new THREE.Vector3();
|
|
66
70
|
const _walkPos = new THREE.Vector3();
|
|
67
71
|
const _reticleNdc = new THREE.Vector2(0, 0);
|
|
68
72
|
const _reticleRaycaster = new THREE.Raycaster();
|
|
69
|
-
/** How far ahead (metres)
|
|
73
|
+
/** How far ahead (metres) an interactive part counts as "in view" for activation. */
|
|
70
74
|
const WALK_REACH = 3;
|
|
71
75
|
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;
|
|
76
|
+
/**
|
|
77
|
+
* The baked node under a pointer that a click can land on: the nearest hit
|
|
78
|
+
* whose identity is not a kind that only frames (site, building, level, zone,
|
|
79
|
+
* ceiling) and is not on a hidden floor, with the hit in its level's local XZ.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveGlbPick(hits, identity) {
|
|
82
|
+
for (const hit of hits) {
|
|
83
|
+
const node = findIdentityAncestor(hit.object);
|
|
84
|
+
const extras = node?.userData;
|
|
85
|
+
if (!(node && extras?.pascalId) || PASS_THROUGH_KINDS.has(extras.kind ?? ''))
|
|
86
|
+
continue;
|
|
87
|
+
const levelId = findAncestorLevelId(node);
|
|
88
|
+
const level = levelId ? identity.get(levelId) : undefined;
|
|
89
|
+
if (level && !level.visible)
|
|
90
|
+
continue;
|
|
91
|
+
let point = null;
|
|
92
|
+
if (level) {
|
|
93
|
+
level.updateWorldMatrix(true, false);
|
|
94
|
+
_local.copy(hit.point);
|
|
95
|
+
level.worldToLocal(_local);
|
|
96
|
+
point = [_local.x, _local.z];
|
|
97
|
+
}
|
|
98
|
+
return { nodeId: extras.pascalId, point, hitObject: hit.object };
|
|
130
99
|
}
|
|
131
|
-
return
|
|
100
|
+
return null;
|
|
132
101
|
}
|
|
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;
|
|
102
|
+
function pointInZone(x, z, zone) {
|
|
103
|
+
const polygon = [{ outer: zone.polygon, holes: zone.holes }];
|
|
104
|
+
return (containsPoint(polygon, [x, z]) || distanceToBoundary(polygon, [x, z]) <= ZONE_FOOTPRINT_EPSILON);
|
|
182
105
|
}
|
|
183
106
|
/**
|
|
184
|
-
* GLB-consuming viewer scene
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* `
|
|
191
|
-
* host disables the parametric `SelectionManager` (`selectionManager="custom"`).
|
|
107
|
+
* GLB-consuming viewer scene. Loads a baked artifact and drives the viewer's
|
|
108
|
+
* presentation and interaction with no parametric scene graph. A pointer
|
|
109
|
+
* becomes a `GlbPickEvent` the host runs through its selection rules (the
|
|
110
|
+
* same rules as the parametric viewer); the shared `useViewer.selection` and
|
|
111
|
+
* `hoveredId` then drive the outline, and openables play their baked clips
|
|
112
|
+
* when clicked. The host disables the parametric `SelectionManager`
|
|
113
|
+
* (`selectionManager="custom"`).
|
|
192
114
|
*/
|
|
193
|
-
export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
115
|
+
export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes, onObjectsChange, onPick, onWalkthroughChange, }) {
|
|
194
116
|
const gltf = useGLTFKTX2(url);
|
|
195
117
|
const rootRef = useRef(null);
|
|
196
|
-
const
|
|
118
|
+
const actions = useClipActions(gltf.animations, rootRef);
|
|
119
|
+
const proceduralPlayback = useMemo(() => {
|
|
120
|
+
const byNode = new Map();
|
|
121
|
+
gltf.scene.traverse((object) => {
|
|
122
|
+
const extras = object.userData;
|
|
123
|
+
if (extras.kind === 'procedural-item' && extras.pascalId && !byNode.has(extras.pascalId))
|
|
124
|
+
byNode.set(extras.pascalId, { motions: [], clips: new Map() });
|
|
125
|
+
const motion = extras.proceduralMotion;
|
|
126
|
+
if (!motion?.clip)
|
|
127
|
+
return;
|
|
128
|
+
const clip = gltf.animations.find((entry) => entry.name === motion.clip);
|
|
129
|
+
if (!clip)
|
|
130
|
+
return;
|
|
131
|
+
const entry = byNode.get(motion.nodeId) ?? { motions: [], clips: new Map() };
|
|
132
|
+
if (motion.kind === 'spin') {
|
|
133
|
+
entry.motions.push({
|
|
134
|
+
id: motion.groupId,
|
|
135
|
+
partId: motion.partId,
|
|
136
|
+
kind: 'spin',
|
|
137
|
+
axis: 'y',
|
|
138
|
+
pivot: [0, 0, 0],
|
|
139
|
+
amount: (2 * Math.PI) / clip.duration,
|
|
140
|
+
delay: 0,
|
|
141
|
+
duration: 0,
|
|
142
|
+
easing: 'linear',
|
|
143
|
+
});
|
|
144
|
+
entry.clips.set(motion.clip, {
|
|
145
|
+
partId: motion.partId,
|
|
146
|
+
kind: 'spin',
|
|
147
|
+
groupId: entry.clips.get(motion.clip)?.groupId ?? motion.groupId,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
else if (motion.activeWindow &&
|
|
151
|
+
!entry.motions.some((item) => item.partId === motion.partId)) {
|
|
152
|
+
entry.motions.push({
|
|
153
|
+
id: motion.groupId,
|
|
154
|
+
partId: motion.partId,
|
|
155
|
+
kind: 'hinge',
|
|
156
|
+
axis: 'y',
|
|
157
|
+
pivot: [0, 0, 0],
|
|
158
|
+
amount: 1,
|
|
159
|
+
delay: motion.activeWindow[0],
|
|
160
|
+
duration: motion.activeWindow[1] - motion.activeWindow[0],
|
|
161
|
+
easing: 'linear',
|
|
162
|
+
});
|
|
163
|
+
entry.clips.set(motion.clip, {
|
|
164
|
+
partId: motion.partId,
|
|
165
|
+
kind: 'finite',
|
|
166
|
+
groupId: motion.groupId,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
byNode.set(motion.nodeId, entry);
|
|
170
|
+
});
|
|
171
|
+
// Procedural actions need a separate mixer so drei cannot advance their assigned times.
|
|
172
|
+
const mixer = new THREE.AnimationMixer(gltf.scene);
|
|
173
|
+
const entries = new Map([...byNode].map(([nodeId, entry]) => {
|
|
174
|
+
const spinParts = [
|
|
175
|
+
...new Set(entry.motions.filter((motion) => motion.kind === 'spin').map((motion) => motion.partId)),
|
|
176
|
+
];
|
|
177
|
+
const initial = Object.fromEntries(spinParts.map((partId) => [partId, true]));
|
|
178
|
+
return [
|
|
179
|
+
nodeId,
|
|
180
|
+
{
|
|
181
|
+
controller: new ProceduralMotionController(entry.motions, initial),
|
|
182
|
+
clips: entry.clips,
|
|
183
|
+
spinParts,
|
|
184
|
+
sequence: 0,
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
}));
|
|
188
|
+
const proceduralActions = new Map(gltf.animations
|
|
189
|
+
.filter((clip) => [...entries.values()].some((entry) => entry.clips.has(clip.name)))
|
|
190
|
+
.map((clip) => {
|
|
191
|
+
const action = mixer.clipAction(clip);
|
|
192
|
+
action.enabled = true;
|
|
193
|
+
action.paused = true;
|
|
194
|
+
action.loop = clip.name.endsWith(': loop') ? THREE.LoopRepeat : THREE.LoopOnce;
|
|
195
|
+
action.clampWhenFinished = true;
|
|
196
|
+
action.setEffectiveWeight(1);
|
|
197
|
+
action.play();
|
|
198
|
+
return [clip.name, action];
|
|
199
|
+
}));
|
|
200
|
+
return { entries, mixer, actions: proceduralActions };
|
|
201
|
+
}, [gltf.scene, gltf.animations]);
|
|
197
202
|
const camera = useThree((state) => state.camera);
|
|
198
|
-
const raycaster = useThree((state) => state.raycaster);
|
|
199
203
|
const controls = useThree((state) => state.controls);
|
|
200
204
|
const walkthroughMode = useViewer((s) => s.walkthroughMode);
|
|
201
205
|
const textures = useViewer((s) => s.textures);
|
|
@@ -211,7 +215,7 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
211
215
|
return;
|
|
212
216
|
object.traverse((child) => {
|
|
213
217
|
const mesh = child;
|
|
214
|
-
if (!mesh.isMesh
|
|
218
|
+
if (!mesh.isMesh)
|
|
215
219
|
return;
|
|
216
220
|
const ud = mesh.userData;
|
|
217
221
|
if (!ud.__bakedMaterial)
|
|
@@ -312,15 +316,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
312
316
|
}
|
|
313
317
|
if (extras.kind === 'zone' && extras.polygon && extras.polygon.length >= 3) {
|
|
314
318
|
const polygon = extras.polygon;
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
polygon.reduce((sum, [, z]) => sum + z, 0) / polygon.length,
|
|
318
|
-
];
|
|
319
|
+
const holes = extras.holes ?? [];
|
|
320
|
+
const centroid = polygonInteriorPoint({ polygon, holes });
|
|
319
321
|
zoneList.push({
|
|
320
322
|
id: extras.pascalId,
|
|
321
323
|
node: object,
|
|
322
324
|
levelId: findAncestorLevelId(object),
|
|
323
325
|
polygon,
|
|
326
|
+
holes,
|
|
324
327
|
label: extras.label ?? extras.pascalId,
|
|
325
328
|
color: extras.color ?? '#3b82f6',
|
|
326
329
|
centroid,
|
|
@@ -338,9 +341,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
338
341
|
levelsWithZones: new Set(zoneList.map((zone) => zone.levelId)),
|
|
339
342
|
};
|
|
340
343
|
}, [gltf.scene]);
|
|
341
|
-
const zoneById = useMemo(() => new Map(zoneEntries.map((zone) => [zone.id, zone])), [zoneEntries]);
|
|
342
344
|
// Level pascalIds bottom-to-top, for the interactive light pool's level factor.
|
|
343
345
|
const levelOrder = useMemo(() => levels.map((entry) => entry.id), [levels]);
|
|
346
|
+
// Loop clips no other controller plays (a plugin kind's mechanism) run on
|
|
347
|
+
// click and E. As in the editor they start stopped and never persist.
|
|
348
|
+
const loopMechanisms = useMemo(() => bakedLoopMechanisms(identity, new Set([
|
|
349
|
+
...proceduralPlayback.entries.keys(),
|
|
350
|
+
...(interactiveItems ?? []).map((item) => item.pascalId),
|
|
351
|
+
])), [identity, proceduralPlayback, interactiveItems]);
|
|
344
352
|
// The dollhouse hides ceilings/roof — but only their OWN geometry. Items hosted
|
|
345
353
|
// on a ceiling (lamps, fans, recessed lights) are child identity nodes; hiding
|
|
346
354
|
// the whole occluder node would hide them too, so collect just the occluder's
|
|
@@ -363,71 +371,20 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
363
371
|
}
|
|
364
372
|
return meshes;
|
|
365
373
|
}, [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);
|
|
374
|
+
// Open on the building: its saved view, else a fit of its bounds. Later moves
|
|
375
|
+
// belong to the host's navigation (`camera-controls:frame`), never to a click.
|
|
373
376
|
useEffect(() => {
|
|
374
377
|
if (!controls)
|
|
375
378
|
return;
|
|
376
|
-
const
|
|
379
|
+
const bookmark = rootNode?.userData?.camera;
|
|
380
|
+
if (bookmark) {
|
|
377
381
|
const { position: p, target: t } = bookmark;
|
|
378
382
|
controls.setLookAt(p[0], p[1], p[2], t[0], t[1], t[2], true);
|
|
379
383
|
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
384
|
return;
|
|
400
385
|
}
|
|
401
|
-
let bookmarkNode = null;
|
|
402
386
|
_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
|
-
}
|
|
387
|
+
_camBox.setFromObject(rootNode ?? gltf.scene);
|
|
431
388
|
if (_camBox.isEmpty())
|
|
432
389
|
return;
|
|
433
390
|
_camBox.getCenter(_camCenter);
|
|
@@ -435,47 +392,21 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
435
392
|
const distance = Math.max(Math.max(_camSize.x, _camSize.y, _camSize.z) * 2, 15);
|
|
436
393
|
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
394
|
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]);
|
|
395
|
+
}, [controls, rootNode, gltf.scene]);
|
|
458
396
|
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.
|
|
397
|
+
onObjectsChange?.(identity);
|
|
398
|
+
return () => onObjectsChange?.(new Map());
|
|
399
|
+
}, [identity, onObjectsChange]);
|
|
400
|
+
// Apply the viewer's level display to the baked floors each frame, by the
|
|
401
|
+
// parametric LevelSystem's rule (solo, and the levels above the current one
|
|
402
|
+
// hidden when the host asks). Walkthrough always shows the full stacked
|
|
403
|
+
// building (you're standing inside it) — and the first-person collider is
|
|
404
|
+
// built from the visible meshes, so a hidden floor would otherwise drop the
|
|
405
|
+
// player through the world.
|
|
475
406
|
useFrame((_, delta) => {
|
|
476
407
|
if (levels.length === 0)
|
|
477
408
|
return;
|
|
478
|
-
const { levelMode, selection, walkthroughMode } = useViewer.getState();
|
|
409
|
+
const { levelMode, hideLevelsAboveSelection, selection, walkthroughMode } = useViewer.getState();
|
|
479
410
|
const selectedLevel = selection.levelId;
|
|
480
411
|
const selectedIdx = selectedLevel ? levels.findIndex((l) => l.id === selectedLevel) : -1;
|
|
481
412
|
levels.forEach(({ id, node, baseY }, index) => {
|
|
@@ -486,77 +417,71 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
486
417
|
node.position.y = walkthroughMode
|
|
487
418
|
? targetY
|
|
488
419
|
: lerp(node.position.y, targetY, Math.min(1, delta * 12));
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
420
|
+
const { visible, shadowOnly } = walkthroughMode
|
|
421
|
+
? { visible: true, shadowOnly: false }
|
|
422
|
+
: resolveLevelVisibility({
|
|
423
|
+
levelMode,
|
|
424
|
+
hideAbove: hideLevelsAboveSelection,
|
|
425
|
+
hasSelectedLevel: selectedIdx >= 0,
|
|
426
|
+
isSelected: id === selectedLevel,
|
|
427
|
+
index,
|
|
428
|
+
selectedIndex: selectedIdx >= 0 ? selectedIdx : undefined,
|
|
429
|
+
nodeVisible: true,
|
|
430
|
+
});
|
|
431
|
+
if (shadowOnly)
|
|
494
432
|
applyShadowOnly(node);
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
else {
|
|
433
|
+
else
|
|
498
434
|
clearShadowOnly(node);
|
|
499
|
-
|
|
500
|
-
}
|
|
435
|
+
node.visible = visible;
|
|
501
436
|
});
|
|
502
437
|
}, 5);
|
|
503
|
-
const zoneFills = useRef([]);
|
|
504
438
|
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;
|
|
439
|
+
for (const [nodeId, entry] of proceduralPlayback.entries)
|
|
440
|
+
useInteractive
|
|
441
|
+
.getState()
|
|
442
|
+
.initProcedural(nodeId, [...new Set(entry.controller.motions.map((motion) => motion.partId))], entry.spinParts);
|
|
542
443
|
return () => {
|
|
543
|
-
for (const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
444
|
+
for (const nodeId of proceduralPlayback.entries.keys())
|
|
445
|
+
useInteractive.getState().removeProcedural(nodeId);
|
|
446
|
+
proceduralPlayback.mixer.stopAllAction();
|
|
447
|
+
};
|
|
448
|
+
}, [proceduralPlayback]);
|
|
449
|
+
useFrame((_, delta) => {
|
|
450
|
+
for (const [nodeId, entry] of proceduralPlayback.entries) {
|
|
451
|
+
const command = useInteractive.getState().procedural[nodeId]?.motionCommand;
|
|
452
|
+
if (command && command.sequence > entry.sequence) {
|
|
453
|
+
entry.controller.command(command);
|
|
454
|
+
entry.sequence = command.sequence;
|
|
455
|
+
}
|
|
456
|
+
const frame = entry.controller.tick(delta);
|
|
457
|
+
for (const [clipName, binding] of entry.clips) {
|
|
458
|
+
const action = proceduralPlayback.actions.get(clipName);
|
|
459
|
+
if (!action)
|
|
460
|
+
continue;
|
|
461
|
+
if (binding.kind === 'finite')
|
|
462
|
+
action.time = frame.times[binding.partId] ?? 0;
|
|
463
|
+
else {
|
|
464
|
+
const motion = entry.controller.motions.find((item) => item.id === binding.groupId);
|
|
465
|
+
if (motion)
|
|
466
|
+
action.time =
|
|
467
|
+
((frame.spins[motion.id]?.phase ?? 0) / (2 * Math.PI)) * action.getClip().duration;
|
|
548
468
|
}
|
|
549
469
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
},
|
|
470
|
+
}
|
|
471
|
+
proceduralPlayback.mixer.update(0);
|
|
472
|
+
}, -1);
|
|
553
473
|
useEffect(() => {
|
|
474
|
+
const scriptedPrefixes = (interactiveItems ?? [])
|
|
475
|
+
.filter((item) => item.scripted)
|
|
476
|
+
.map((item) => `${item.pascalId}: `);
|
|
554
477
|
for (const [name, action] of Object.entries(actions)) {
|
|
555
478
|
if (!action)
|
|
556
479
|
continue;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
//
|
|
480
|
+
if (proceduralPlayback.actions.has(name))
|
|
481
|
+
continue;
|
|
482
|
+
// ScriptedClips owns each authored clip's playback mode.
|
|
483
|
+
if (scriptedPrefixes.some((prefix) => name.startsWith(prefix)))
|
|
484
|
+
continue;
|
|
560
485
|
if (name.endsWith(': loop')) {
|
|
561
486
|
action.loop = THREE.LoopRepeat;
|
|
562
487
|
action.clampWhenFinished = false;
|
|
@@ -566,11 +491,87 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
566
491
|
action.clampWhenFinished = true;
|
|
567
492
|
}
|
|
568
493
|
}
|
|
569
|
-
}, [actions]);
|
|
494
|
+
}, [actions, proceduralPlayback, interactiveItems]);
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
if (loopMechanisms.size === 0)
|
|
497
|
+
return;
|
|
498
|
+
const apply = (running) => {
|
|
499
|
+
for (const [id, clips] of loopMechanisms) {
|
|
500
|
+
for (const name of clips) {
|
|
501
|
+
const action = actions[name];
|
|
502
|
+
if (!action)
|
|
503
|
+
continue;
|
|
504
|
+
if (running[id]) {
|
|
505
|
+
action.enabled = true;
|
|
506
|
+
action.paused = false;
|
|
507
|
+
if (!action.isRunning())
|
|
508
|
+
action.play();
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
action.stop();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
apply(useInteractive.getState().mechanisms);
|
|
517
|
+
const unsubscribe = useInteractive.subscribe((state, previous) => {
|
|
518
|
+
if (state.mechanisms !== previous.mechanisms)
|
|
519
|
+
apply(state.mechanisms);
|
|
520
|
+
});
|
|
521
|
+
return () => {
|
|
522
|
+
unsubscribe();
|
|
523
|
+
for (const id of loopMechanisms.keys())
|
|
524
|
+
useInteractive.getState().removeMechanism(id);
|
|
525
|
+
};
|
|
526
|
+
}, [actions, loopMechanisms]);
|
|
527
|
+
// Items the walkthrough operates through their toggles; others fall through to loop clips.
|
|
528
|
+
const toggleableItem = useCallback((pascalId) => interactiveItems?.find((item) => item.pascalId === pascalId &&
|
|
529
|
+
item.interactive.controls.some((control) => control.kind === 'toggle')), [interactiveItems]);
|
|
530
|
+
// A click opens and closes an item with an `open` clip (an authored object,
|
|
531
|
+
// a scripted window or door), like a door.
|
|
532
|
+
const toggleOpenControl = useCallback((identityNode) => {
|
|
533
|
+
const item = toggleableItem(identityNode.userData.pascalId);
|
|
534
|
+
if (!item || itemInteraction(item.interactive).kind !== 'open')
|
|
535
|
+
return false;
|
|
536
|
+
operateItem(item.pascalId, item.interactive);
|
|
537
|
+
return true;
|
|
538
|
+
}, [toggleableItem]);
|
|
539
|
+
const toggleLoopMechanism = useCallback((identityNode) => {
|
|
540
|
+
const id = identityNode.userData.pascalId;
|
|
541
|
+
if (!(id && loopMechanisms.has(id)))
|
|
542
|
+
return false;
|
|
543
|
+
const state = useInteractive.getState();
|
|
544
|
+
state.setMechanism(id, !state.mechanisms[id]);
|
|
545
|
+
return true;
|
|
546
|
+
}, [loopMechanisms]);
|
|
570
547
|
const openIds = useRef(new Set());
|
|
548
|
+
const toggleProcedural = useCallback((hit, identityNode) => {
|
|
549
|
+
const extras = identityNode.userData;
|
|
550
|
+
if (extras.kind !== 'procedural-item')
|
|
551
|
+
return false;
|
|
552
|
+
const part = findProceduralMotionAncestor(hit);
|
|
553
|
+
if (part?.clip) {
|
|
554
|
+
useInteractive.getState().toggleProceduralPart(part.nodeId, part.partId);
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
const nodeId = extras.pascalId;
|
|
558
|
+
const entry = proceduralPlayback.entries.get(nodeId);
|
|
559
|
+
if (!entry)
|
|
560
|
+
return false;
|
|
561
|
+
const parts = [...new Set(entry.controller.motions.map((motion) => motion.partId))];
|
|
562
|
+
if (parts.length === 0) {
|
|
563
|
+
useInteractive.getState().toggleProceduralLights(nodeId);
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
const active = useInteractive.getState().procedural[nodeId]?.parts;
|
|
567
|
+
useInteractive
|
|
568
|
+
.getState()
|
|
569
|
+
.setProceduralParts(nodeId, parts, !parts.some((partId) => active?.[partId]));
|
|
570
|
+
return true;
|
|
571
|
+
}, [proceduralPlayback]);
|
|
571
572
|
const toggleOpenable = useCallback((node) => {
|
|
572
573
|
const extras = node.userData;
|
|
573
|
-
const clipName = extras.clips?.
|
|
574
|
+
const clipName = extras.clips?.find((name) => name.endsWith(': open'));
|
|
574
575
|
if (!extras.openable || !clipName)
|
|
575
576
|
return;
|
|
576
577
|
const action = actions[clipName];
|
|
@@ -598,100 +599,24 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
598
599
|
continue;
|
|
599
600
|
_local.copy(worldPoint);
|
|
600
601
|
entry.node.worldToLocal(_local);
|
|
601
|
-
if (
|
|
602
|
+
if (pointInZone(_local.x, _local.z, entry))
|
|
602
603
|
return entry;
|
|
603
604
|
}
|
|
604
605
|
return null;
|
|
605
606
|
}, [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) => {
|
|
607
|
+
// Per-frame: hide the focused floor's ceilings and roof when it has rooms
|
|
608
|
+
// (dollhouse), and sync the outline post-FX from the shared selection and
|
|
609
|
+
// hover. Rooms highlight through the host's room layer, not the outline.
|
|
610
|
+
useFrame(() => {
|
|
684
611
|
const state = useViewer.getState();
|
|
685
612
|
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.
|
|
613
|
+
// Walkthrough is a first-person tour: no dollhouse cutaway, no selection
|
|
614
|
+
// outline — you're standing inside the real building.
|
|
690
615
|
const walk = state.walkthroughMode;
|
|
691
|
-
// Dollhouse: hide ceilings + roof so the rooms
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
616
|
+
// Dollhouse: hide ceilings + roof so the rooms are visible from above and
|
|
617
|
+
// the ray reaches their contents — but only when the focused level actually
|
|
618
|
+
// has rooms. Focusing a zone-less floor keeps the building intact
|
|
619
|
+
// (otherwise its roof would just vanish with nothing to show).
|
|
695
620
|
const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId);
|
|
696
621
|
// Shadow-caster-only: hidden roof/ceiling meshes keep casting sun shadows
|
|
697
622
|
// so interiors show window light patches instead of uniform sun flood.
|
|
@@ -701,47 +626,23 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
701
626
|
else
|
|
702
627
|
clearShadowOnly(mesh);
|
|
703
628
|
}
|
|
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
629
|
outliner.selectedObjects.length = 0;
|
|
730
630
|
outliner.hoveredObjects.length = 0;
|
|
731
631
|
if (walk)
|
|
732
632
|
return;
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
outliner.
|
|
633
|
+
for (const id of selection.selectedIds) {
|
|
634
|
+
const object = identity.get(id);
|
|
635
|
+
if (object)
|
|
636
|
+
outliner.selectedObjects.push(object);
|
|
637
|
+
}
|
|
638
|
+
const hovered = state.hoveredId ? identity.get(state.hoveredId) : undefined;
|
|
639
|
+
if (hovered &&
|
|
640
|
+
hovered.userData.kind !== 'zone' &&
|
|
641
|
+
!outliner.selectedObjects.includes(hovered)) {
|
|
642
|
+
outliner.hoveredObjects.push(hovered);
|
|
742
643
|
}
|
|
743
644
|
});
|
|
744
|
-
// ── Walkthrough: first-person HUD +
|
|
645
|
+
// ── Walkthrough: first-person HUD + interaction ────────────────────────────
|
|
745
646
|
const walkDoorRef = useRef(null);
|
|
746
647
|
const lastWalkKey = useRef(null);
|
|
747
648
|
// Each frame in walkthrough, report the floor + room the camera stands in and
|
|
@@ -769,11 +670,51 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
769
670
|
if (hit) {
|
|
770
671
|
const node = findIdentityAncestor(hit.object);
|
|
771
672
|
const extras = node?.userData;
|
|
772
|
-
|
|
773
|
-
|
|
673
|
+
const lightOnly = extras?.pascalId &&
|
|
674
|
+
interactiveItems?.some((item) => item.pascalId === extras.pascalId &&
|
|
675
|
+
item.procedural?.lights.length &&
|
|
676
|
+
operableParts(item.procedural.recipe ?? { parts: item.procedural.parts }).length === 0);
|
|
677
|
+
const item = toggleableItem(extras?.pascalId);
|
|
678
|
+
if (node && extras?.kind === 'procedural-item' && (extras.clips?.length || lightOnly)) {
|
|
679
|
+
doorNode = { hit: hit.object, node };
|
|
680
|
+
const part = findProceduralMotionAncestor(hit.object);
|
|
681
|
+
const state = useInteractive.getState().procedural[extras.pascalId];
|
|
682
|
+
const isOpen = lightOnly
|
|
683
|
+
? (state?.lightsOn ?? useInteractive.getState().lampDefault)
|
|
684
|
+
: part
|
|
685
|
+
? Boolean(state?.parts[part.partId])
|
|
686
|
+
: Object.values(state?.parts ?? {}).some(Boolean);
|
|
687
|
+
const clips = part?.clip ? [part.clip] : extras.clips;
|
|
688
|
+
const isSpin = part
|
|
689
|
+
? part.kind === 'spin'
|
|
690
|
+
: (clips?.every((clip) => clip.endsWith(': loop')) ?? false);
|
|
691
|
+
const label = lightOnly
|
|
692
|
+
? (extras.label ?? 'Lights')
|
|
693
|
+
: (part?.partId.replaceAll('_', ' ') ?? extras.label ?? 'Item');
|
|
694
|
+
door = {
|
|
695
|
+
label,
|
|
696
|
+
isOpen,
|
|
697
|
+
verb: lightOnly || isSpin ? (isOpen ? 'turn off' : 'turn on') : isOpen ? 'close' : 'open',
|
|
698
|
+
};
|
|
699
|
+
doorId = `${extras.pascalId}:${part?.partId ?? 'all'}`;
|
|
700
|
+
}
|
|
701
|
+
else if (node && item) {
|
|
702
|
+
doorNode = { hit: hit.object, node };
|
|
703
|
+
doorId = item.pascalId;
|
|
704
|
+
const prompt = itemPrompt(item.pascalId, item.label, item.interactive);
|
|
705
|
+
door = { label: prompt.label, isOpen: prompt.isOn, verb: prompt.verb };
|
|
706
|
+
}
|
|
707
|
+
else if (node && extras?.openable && extras.clips?.length) {
|
|
708
|
+
doorNode = { hit: hit.object, node };
|
|
774
709
|
doorId = extras.pascalId;
|
|
775
710
|
door = { label: extras.label ?? 'Door', isOpen: openIds.current.has(doorId) };
|
|
776
711
|
}
|
|
712
|
+
else if (node && extras?.pascalId && loopMechanisms.has(extras.pascalId)) {
|
|
713
|
+
doorNode = { hit: hit.object, node };
|
|
714
|
+
doorId = extras.pascalId;
|
|
715
|
+
const isOpen = Boolean(useInteractive.getState().mechanisms[doorId]);
|
|
716
|
+
door = { label: extras.label ?? 'Item', isOpen, verb: isOpen ? 'turn off' : 'turn on' };
|
|
717
|
+
}
|
|
777
718
|
}
|
|
778
719
|
walkDoorRef.current = doorNode;
|
|
779
720
|
const key = `${floor?.id ?? ''}|${zone?.id ?? ''}|${door ? `${doorId}:${door.isOpen}` : ''}`;
|
|
@@ -785,9 +726,17 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
785
726
|
// E or click activates the openable in view. The click also re-locks the
|
|
786
727
|
// pointer through the walkthrough controller; no selection happens.
|
|
787
728
|
const activateWalkDoor = useCallback(() => {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
729
|
+
const target = walkDoorRef.current;
|
|
730
|
+
if (!target)
|
|
731
|
+
return;
|
|
732
|
+
const item = toggleableItem(target.node.userData.pascalId);
|
|
733
|
+
if (item) {
|
|
734
|
+
operateItem(item.pascalId, item.interactive);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (!(toggleProcedural(target.hit, target.node) || toggleLoopMechanism(target.node)))
|
|
738
|
+
toggleOpenable(target.node);
|
|
739
|
+
}, [toggleableItem, toggleLoopMechanism, toggleOpenable, toggleProcedural]);
|
|
791
740
|
useEffect(() => {
|
|
792
741
|
if (!walkthroughMode)
|
|
793
742
|
return;
|
|
@@ -814,83 +763,57 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
814
763
|
const lastHover = useRef(null);
|
|
815
764
|
const handlePointerMove = useCallback((event) => {
|
|
816
765
|
event.stopPropagation();
|
|
817
|
-
if (walkthroughMode)
|
|
766
|
+
if (walkthroughMode || !onPick)
|
|
818
767
|
return;
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}, [resolveTarget, onHoverChange, walkthroughMode]);
|
|
768
|
+
const pick = resolveGlbPick(event.intersections, identity);
|
|
769
|
+
document.body.style.cursor = pick ? 'pointer' : 'auto';
|
|
770
|
+
const key = pick ? `${pick.nodeId}:${pick.point?.join(',') ?? ''}` : null;
|
|
771
|
+
if (key === lastHover.current)
|
|
772
|
+
return;
|
|
773
|
+
lastHover.current = key;
|
|
774
|
+
onPick({ type: 'hover', pick, modifiers: modifiersOf(event.nativeEvent) });
|
|
775
|
+
}, [identity, onPick, walkthroughMode]);
|
|
828
776
|
const handlePointerOut = useCallback(() => {
|
|
829
|
-
hoveredTarget.current = null;
|
|
830
777
|
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.
|
|
778
|
+
if (lastHover.current === null)
|
|
779
|
+
return;
|
|
780
|
+
lastHover.current = null;
|
|
781
|
+
onPick?.({ type: 'hover', pick: null, modifiers: NO_MODIFIERS });
|
|
782
|
+
}, [onPick]);
|
|
783
|
+
// The host's rules decide the selection; an openable the click selects (a
|
|
784
|
+
// door, a window, an item with an `open` clip, a mechanism) also plays.
|
|
839
785
|
const handleClick = useCallback((event) => {
|
|
840
786
|
event.stopPropagation();
|
|
841
787
|
// Walkthrough handles its own door activation (E / canvas click) and never
|
|
842
|
-
// selects
|
|
843
|
-
if (walkthroughMode)
|
|
788
|
+
// selects.
|
|
789
|
+
if (walkthroughMode || !onPick)
|
|
844
790
|
return;
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
if (!selection.levelId) {
|
|
849
|
-
if (target) {
|
|
850
|
-
setLevelMode('solo');
|
|
851
|
-
setSelection({ levelId: target.id });
|
|
852
|
-
}
|
|
791
|
+
const pick = resolveGlbPick(event.intersections, identity);
|
|
792
|
+
onPick({ type: 'click', pick, modifiers: modifiersOf(event.nativeEvent) });
|
|
793
|
+
if (!pick || !useViewer.getState().selection.selectedIds.includes(pick.nodeId))
|
|
853
794
|
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
|
-
}
|
|
795
|
+
const object = identity.get(pick.nodeId);
|
|
796
|
+
if (!object)
|
|
865
797
|
return;
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
798
|
+
if (!(toggleOpenControl(object) ||
|
|
799
|
+
toggleProcedural(pick.hitObject, object) ||
|
|
800
|
+
toggleLoopMechanism(object)))
|
|
801
|
+
toggleOpenable(object);
|
|
802
|
+
}, [
|
|
803
|
+
identity,
|
|
804
|
+
onPick,
|
|
805
|
+
toggleLoopMechanism,
|
|
806
|
+
toggleOpenControl,
|
|
807
|
+
toggleOpenable,
|
|
808
|
+
toggleProcedural,
|
|
809
|
+
walkthroughMode,
|
|
810
|
+
]);
|
|
811
|
+
// A click on empty space clears the room and the element (the floor stays).
|
|
812
|
+
const handlePointerMissed = useCallback((event) => {
|
|
813
|
+
if (useViewer.getState().walkthroughMode || !onPick)
|
|
881
814
|
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
|
-
}, []);
|
|
815
|
+
onPick({ type: 'click', pick: null, modifiers: modifiersOf(event) });
|
|
816
|
+
}, [onPick]);
|
|
894
817
|
useEffect(() => () => {
|
|
895
818
|
const { outliner } = useViewer.getState();
|
|
896
819
|
outliner.selectedObjects.length = 0;
|
|
@@ -900,23 +823,14 @@ export function GlbScene({ url, interactiveItems, referenceNodes, replaceNodes,
|
|
|
900
823
|
for (const mesh of occluderOwnMeshes)
|
|
901
824
|
clearShadowOnly(mesh);
|
|
902
825
|
}, [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)))] }));
|
|
826
|
+
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] }));
|
|
827
|
+
}
|
|
828
|
+
const NO_MODIFIERS = {
|
|
829
|
+
alt: false,
|
|
830
|
+
ctrl: false,
|
|
831
|
+
meta: false,
|
|
832
|
+
shift: false,
|
|
833
|
+
};
|
|
834
|
+
function modifiersOf(event) {
|
|
835
|
+
return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey };
|
|
922
836
|
}
|