@pascal-app/viewer 0.9.1 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -5
- package/dist/components/error-boundary.d.ts +6 -0
- package/dist/components/error-boundary.d.ts.map +1 -1
- package/dist/components/error-boundary.js +6 -0
- package/dist/components/renderers/node-renderer.d.ts +5 -1
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +5 -2
- package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/parametric-node-renderer.js +7 -4
- package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
- package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
- package/dist/components/viewer/bvh-ecctrl.js +552 -0
- package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.js +59 -4
- package/dist/components/viewer/glb-interactive.d.ts +41 -0
- package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
- package/dist/components/viewer/glb-interactive.js +394 -0
- package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
- package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
- package/dist/components/viewer/glb-reference-nodes.js +80 -0
- package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
- package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
- package/dist/components/viewer/glb-replace-instances.js +61 -0
- package/dist/components/viewer/glb-scene.d.ts +54 -0
- package/dist/components/viewer/glb-scene.d.ts.map +1 -0
- package/dist/components/viewer/glb-scene.js +920 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts +22 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
- package/dist/components/viewer/glb-walkthrough-controller.js +445 -0
- package/dist/components/viewer/index.d.ts +19 -2
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +113 -12
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +36 -5
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
- package/dist/components/viewer/perf-monitor.js +17 -2
- package/dist/components/viewer/post-processing.d.ts +7 -1
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +180 -24
- package/dist/components/viewer/registered-systems.d.ts.map +1 -1
- package/dist/components/viewer/registered-systems.js +6 -4
- package/dist/components/viewer/scene-bvh.d.ts +2 -1
- package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
- package/dist/components/viewer/scene-bvh.js +3 -0
- package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
- package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
- package/dist/components/viewer/scene-bvh.test.js +13 -0
- package/dist/components/viewer/scene-environment.d.ts +2 -0
- package/dist/components/viewer/scene-environment.d.ts.map +1 -0
- package/dist/components/viewer/scene-environment.js +79 -0
- package/dist/components/viewer/selection-manager.d.ts.map +1 -1
- package/dist/components/viewer/selection-manager.js +23 -12
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +2 -24
- package/dist/hooks/use-node-events.d.ts +1 -1
- package/dist/hooks/use-node-events.d.ts.map +1 -1
- package/dist/hooks/use-node-events.js +22 -14
- package/dist/index.d.ts +24 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -13
- package/dist/lib/backdrop.d.ts +33 -0
- package/dist/lib/backdrop.d.ts.map +1 -0
- package/dist/lib/backdrop.js +92 -0
- package/dist/lib/box-uv.d.ts +13 -0
- package/dist/lib/box-uv.d.ts.map +1 -0
- package/dist/lib/box-uv.js +32 -0
- package/dist/lib/csg-utils.d.ts +3 -9
- package/dist/lib/csg-utils.d.ts.map +1 -1
- package/dist/lib/csg-utils.js +65 -5
- package/dist/lib/csg-utils.test.d.ts +2 -0
- package/dist/lib/csg-utils.test.d.ts.map +1 -0
- package/dist/lib/csg-utils.test.js +45 -0
- package/dist/lib/edge-style.d.ts +1 -0
- package/dist/lib/edge-style.d.ts.map +1 -1
- package/dist/lib/edge-style.js +26 -6
- package/dist/lib/hero-pose.d.ts +47 -0
- package/dist/lib/hero-pose.d.ts.map +1 -0
- package/dist/lib/hero-pose.js +227 -0
- package/dist/lib/ink-edges.d.ts +1 -1
- package/dist/lib/ink-edges.d.ts.map +1 -1
- package/dist/lib/ink-edges.js +13 -7
- package/dist/lib/ktx2-loader.d.ts +46 -0
- package/dist/lib/ktx2-loader.d.ts.map +1 -0
- package/dist/lib/ktx2-loader.js +142 -0
- package/dist/lib/layers.d.ts +10 -0
- package/dist/lib/layers.d.ts.map +1 -1
- package/dist/lib/layers.js +10 -0
- package/dist/lib/materials.d.ts +16 -1
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +158 -34
- package/dist/lib/materials.test.d.ts +2 -0
- package/dist/lib/materials.test.d.ts.map +1 -0
- package/dist/lib/materials.test.js +25 -0
- package/dist/lib/merged-outline-node.test.d.ts +2 -0
- package/dist/lib/merged-outline-node.test.d.ts.map +1 -0
- package/dist/lib/merged-outline-node.test.js +22 -0
- package/dist/lib/scene-themes.d.ts +1 -0
- package/dist/lib/scene-themes.d.ts.map +1 -1
- package/dist/lib/scene-themes.js +25 -16
- package/dist/lib/shadow-only.d.ts +4 -0
- package/dist/lib/shadow-only.d.ts.map +1 -0
- package/dist/lib/shadow-only.js +36 -0
- package/dist/lib/snapshot-pipeline.d.ts +42 -0
- package/dist/lib/snapshot-pipeline.d.ts.map +1 -0
- package/dist/lib/snapshot-pipeline.js +254 -0
- package/dist/lib/terrain-perimeter-fill.d.ts +7 -0
- package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -0
- package/dist/lib/terrain-perimeter-fill.js +75 -0
- package/dist/lib/texture-node-guard.d.ts +13 -0
- package/dist/lib/texture-node-guard.d.ts.map +1 -0
- package/dist/lib/texture-node-guard.js +39 -0
- package/dist/lib/texture-reference.d.ts +31 -0
- package/dist/lib/texture-reference.d.ts.map +1 -0
- package/dist/lib/texture-reference.js +139 -0
- package/dist/lib/texture-reference.test.d.ts +2 -0
- package/dist/lib/texture-reference.test.d.ts.map +1 -0
- package/dist/lib/texture-reference.test.js +86 -0
- package/dist/lib/tsl-compat.d.ts +12 -0
- package/dist/lib/tsl-compat.d.ts.map +1 -0
- package/dist/lib/tsl-compat.js +13 -0
- package/dist/store/use-viewer.d.ts +73 -18
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +191 -4
- package/dist/store/use-viewer.test.d.ts +2 -0
- package/dist/store/use-viewer.test.d.ts.map +1 -0
- package/dist/store/use-viewer.test.js +61 -0
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +12 -5
- package/dist/systems/door/door-animation.test.d.ts +2 -0
- package/dist/systems/door/door-animation.test.d.ts.map +1 -0
- package/dist/systems/door/door-animation.test.js +51 -0
- package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
- package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
- package/dist/systems/door/door-floor-alignment.test.js +50 -0
- package/dist/systems/door/door-system.d.ts +24 -0
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +494 -161
- package/dist/systems/fence/fence-system.d.ts +17 -0
- package/dist/systems/fence/fence-system.d.ts.map +1 -1
- package/dist/systems/fence/fence-system.js +221 -53
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
- package/dist/systems/floor-elevation/floor-elevation-system.js +30 -7
- package/dist/systems/geometry/geometry-system.d.ts +7 -0
- package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
- package/dist/systems/geometry/geometry-system.js +95 -26
- package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
- package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
- package/dist/systems/geometry/geometry-system.test.js +30 -0
- package/dist/systems/interactive/control-widget.d.ts +10 -0
- package/dist/systems/interactive/control-widget.d.ts.map +1 -0
- package/dist/systems/interactive/control-widget.js +35 -0
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +55 -56
- package/dist/systems/item/item-system.d.ts.map +1 -1
- package/dist/systems/item/item-system.js +20 -6
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +34 -9
- package/dist/systems/level/level-utils.d.ts +0 -3
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +7 -45
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +29 -8
- package/dist/systems/roof/roof-system.d.ts +21 -8
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +1079 -221
- package/dist/systems/slab/slab-system.d.ts +7 -4
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +31 -83
- package/dist/systems/slab/slab-system.test.js +54 -14
- package/dist/systems/stair/stair-system.js +7 -4
- package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
- package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
- package/dist/systems/wall/opening-cutout-geometry.js +167 -0
- package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
- package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
- package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +49 -17
- package/dist/systems/wall/wall-materials.d.ts +11 -7
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +244 -35
- package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
- package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
- package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
- package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-support-extension.test.js +116 -0
- package/dist/systems/wall/wall-system.d.ts +4 -8
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +397 -177
- package/dist/systems/wall/wall-terrain-bottom.test.d.ts +2 -0
- package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-terrain-bottom.test.js +34 -0
- package/dist/systems/window/window-animation-system.d.ts +12 -0
- package/dist/systems/window/window-animation-system.d.ts.map +1 -1
- package/dist/systems/window/window-animation-system.js +16 -5
- package/dist/systems/window/window-system.d.ts +7 -0
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +240 -14
- package/package.json +4 -4
- package/dist/components/viewer/ground-occluder.d.ts +0 -2
- package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
- package/dist/components/viewer/ground-occluder.js +0 -67
- package/dist/components/viewer/walkthrough-controls.d.ts +0 -2
- package/dist/components/viewer/walkthrough-controls.d.ts.map +0 -1
- package/dist/components/viewer/walkthrough-controls.js +0 -131
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { getEffectiveNode, nodeRegistry, sceneRegistry, useScene, } from '@pascal-app/core';
|
|
2
|
+
import { getEffectiveNode, nodeRegistry, sceneRegistry, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
|
|
3
3
|
import { useFrame } from '@react-three/fiber';
|
|
4
4
|
import { useEffect, useRef } from 'react';
|
|
5
5
|
import { FrontSide } from 'three';
|
|
@@ -43,11 +43,24 @@ export const GeometrySystem = () => {
|
|
|
43
43
|
const textures = useViewer((s) => s.textures);
|
|
44
44
|
const colorPreset = useViewer((s) => s.colorPreset);
|
|
45
45
|
const sceneTheme = useViewer((s) => s.sceneTheme);
|
|
46
|
+
const bumpGeometryRevision = useViewer((s) => s.bumpGeometryRevision);
|
|
47
|
+
// The shared scene-material library, threaded into each builder's ctx so
|
|
48
|
+
// pure geometry builders can resolve `scene:<id>` slot refs without
|
|
49
|
+
// importing `useScene`.
|
|
50
|
+
const sceneMaterials = useScene((s) => s.materials);
|
|
46
51
|
// Per-node cache of the last-built geometry key (for kinds that declare
|
|
47
52
|
// `def.geometryKey`). Lets us skip a dispose+rebuild when a node is dirty
|
|
48
53
|
// but its geometry inputs are unchanged — e.g. an item reparenting onto a
|
|
49
54
|
// shelf dirties the shelf without altering its boards.
|
|
50
55
|
const builtGeometryKeyRef = useRef(new Map());
|
|
56
|
+
// Re-mark every geometry-backed node dirty whenever a viewer appearance
|
|
57
|
+
// value changes, so `def.geometry` builders re-run and pick up the new
|
|
58
|
+
// shading / texture / preset / theme. These four are deliberate re-run
|
|
59
|
+
// TRIGGERS, not values read in the body — the effect re-fires on any
|
|
60
|
+
// change. They're primitives (stable by value), so listing them is safe;
|
|
61
|
+
// biome flags them as "unnecessary" because the body doesn't reference
|
|
62
|
+
// them, but dropping them silently breaks appearance-mode switching.
|
|
63
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: shading/textures/colorPreset/sceneTheme are intentional re-run triggers; removing them stops geometry from rebuilding on appearance change.
|
|
51
64
|
useEffect(() => {
|
|
52
65
|
const nodes = useScene.getState().nodes;
|
|
53
66
|
for (const node of Object.values(nodes)) {
|
|
@@ -57,10 +70,30 @@ export const GeometrySystem = () => {
|
|
|
57
70
|
}
|
|
58
71
|
}
|
|
59
72
|
}, [shading, textures, colorPreset, sceneTheme]);
|
|
73
|
+
// Editing a scene material must re-colour every geometry node that
|
|
74
|
+
// references it through a `scene:<id>` slot ref. Such a node's
|
|
75
|
+
// `geometryKey` is unchanged (only the referenced material's contents
|
|
76
|
+
// moved), so clear its cached key to defeat the skip in the rebuild loop,
|
|
77
|
+
// then mark it dirty. Scoped to nodes carrying a `scene:` ref so an
|
|
78
|
+
// unrelated material edit doesn't churn the whole scene.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
void sceneMaterials;
|
|
81
|
+
const nodes = useScene.getState().nodes;
|
|
82
|
+
for (const node of Object.values(nodes)) {
|
|
83
|
+
const def = nodeRegistry.get(node.type);
|
|
84
|
+
if (!def?.geometry)
|
|
85
|
+
continue;
|
|
86
|
+
if (!nodeReferencesSceneMaterial(node))
|
|
87
|
+
continue;
|
|
88
|
+
builtGeometryKeyRef.current.delete(node.id);
|
|
89
|
+
useScene.getState().markDirty(node.id);
|
|
90
|
+
}
|
|
91
|
+
}, [sceneMaterials]);
|
|
60
92
|
useFrame(() => {
|
|
61
93
|
if (dirtyNodes.size === 0)
|
|
62
94
|
return;
|
|
63
95
|
const nodes = useScene.getState().nodes;
|
|
96
|
+
let rebuiltGeometry = false;
|
|
64
97
|
const batches = new Map();
|
|
65
98
|
const dirtyIds = [];
|
|
66
99
|
dirtyNodes.forEach((id) => {
|
|
@@ -132,17 +165,17 @@ export const GeometrySystem = () => {
|
|
|
132
165
|
// never skipped. This kills the board remount + pointer enter/leave
|
|
133
166
|
// churn when an item reparents onto a shelf.
|
|
134
167
|
if (def.geometryKey) {
|
|
135
|
-
const
|
|
136
|
-
|
|
168
|
+
const childLiveOverrideKey = liveChildOverrideKey(node);
|
|
169
|
+
const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}|${childLiveOverrideKey}`;
|
|
170
|
+
if (shouldReuseGeometryBuild(builtGeometryKeyRef.current, id, group, builtKey)) {
|
|
137
171
|
clearDirty(id);
|
|
138
172
|
continue;
|
|
139
173
|
}
|
|
140
|
-
builtGeometryKeyRef.current.set(id, builtKey);
|
|
141
174
|
}
|
|
142
175
|
const parentId = (node.parentId ?? null);
|
|
143
176
|
const key = `${node.type}::${parentId ?? ''}`;
|
|
144
177
|
const levelData = levelDataByBatch.get(key);
|
|
145
|
-
const ctx = buildGeometryContext(effectiveNode, nodes, levelData);
|
|
178
|
+
const ctx = buildGeometryContext(effectiveNode, nodes, levelData, sceneMaterials);
|
|
146
179
|
// The builder is typed against the kind's specific node — at the
|
|
147
180
|
// generic system level we lose that refinement, so the cast lands
|
|
148
181
|
// here. Builders are responsible for trusting their schema.
|
|
@@ -152,22 +185,10 @@ export const GeometrySystem = () => {
|
|
|
152
185
|
}
|
|
153
186
|
disposeChildren(group);
|
|
154
187
|
for (const child of [...built.children]) {
|
|
155
|
-
|
|
156
|
-
// can dispose only THIS rebuild's outputs and leave React-
|
|
157
|
-
// mounted siblings (hosted items inside a shelf / slab / etc.)
|
|
158
|
-
// alone. Without this, a parent rebuild triggered by a child
|
|
159
|
-
// event (e.g. an item reparenting onto a shelf calls
|
|
160
|
-
// `dirtyNodes.add(parent)` in `ItemRenderer`'s effect) would
|
|
161
|
-
// wipe ALL of the parent group's children — including the
|
|
162
|
-
// freshly-mounted item — leaving the item in scene state but
|
|
163
|
-
// invisible.
|
|
164
|
-
;
|
|
165
|
-
child.userData = {
|
|
166
|
-
...child.userData,
|
|
167
|
-
__fromGeometry: true,
|
|
168
|
-
};
|
|
188
|
+
markGeometryBuildOutput(child);
|
|
169
189
|
group.add(child);
|
|
170
190
|
}
|
|
191
|
+
rebuiltGeometry = true;
|
|
171
192
|
// NOTE: we intentionally do NOT reset `group.position` / `group.rotation`
|
|
172
193
|
// here. The `ParametricNodeRenderer` binds them via JSX (`position={...}`
|
|
173
194
|
// / `rotation={...}`) driven by `useLiveTransforms` during drag and
|
|
@@ -178,17 +199,45 @@ export const GeometrySystem = () => {
|
|
|
178
199
|
// origin. Geometry builders are expected to emit local-space children.
|
|
179
200
|
clearDirty(id);
|
|
180
201
|
}
|
|
202
|
+
if (rebuiltGeometry)
|
|
203
|
+
bumpGeometryRevision();
|
|
181
204
|
}, 2);
|
|
182
205
|
return null;
|
|
183
206
|
};
|
|
184
|
-
function
|
|
185
|
-
const
|
|
207
|
+
function liveChildOverrideKey(node) {
|
|
208
|
+
const childIds = node.children;
|
|
209
|
+
if (!Array.isArray(childIds) || childIds.length === 0)
|
|
210
|
+
return '';
|
|
211
|
+
const overrides = useLiveNodeOverrides.getState().overrides;
|
|
212
|
+
const entries = [];
|
|
213
|
+
for (const childId of childIds) {
|
|
214
|
+
const override = overrides.get(childId);
|
|
215
|
+
if (override)
|
|
216
|
+
entries.push([childId, override]);
|
|
217
|
+
}
|
|
218
|
+
return entries.length === 0 ? '' : JSON.stringify(entries);
|
|
219
|
+
}
|
|
220
|
+
function nodeReferencesSceneMaterial(node) {
|
|
221
|
+
const slots = node.slots;
|
|
222
|
+
if (!slots)
|
|
223
|
+
return false;
|
|
224
|
+
for (const ref of Object.values(slots)) {
|
|
225
|
+
if (typeof ref === 'string' && ref.startsWith('scene:'))
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
function buildGeometryContext(node, nodes, levelData, materials) {
|
|
231
|
+
const resolve = (id) => {
|
|
232
|
+
const resolved = nodes[id];
|
|
233
|
+
return resolved ? getEffectiveNode(resolved) : undefined;
|
|
234
|
+
};
|
|
186
235
|
const childIds = node.children;
|
|
187
236
|
const children = Array.isArray(childIds)
|
|
188
|
-
? childIds.map((cid) =>
|
|
237
|
+
? childIds.map((cid) => resolve(cid)).filter((n) => n !== undefined)
|
|
189
238
|
: [];
|
|
190
239
|
const parentId = node.parentId;
|
|
191
|
-
const parent = parentId ? (
|
|
240
|
+
const parent = parentId ? (resolve(parentId) ?? null) : null;
|
|
192
241
|
// Siblings = same kind, same parent, excluding self. Walks the parent's
|
|
193
242
|
// children array; falls back to scanning the whole scene if the parent
|
|
194
243
|
// doesn't carry a `children` list (rare — most parents do).
|
|
@@ -199,16 +248,18 @@ function buildGeometryContext(node, nodes, levelData) {
|
|
|
199
248
|
for (const sid of parentChildIds) {
|
|
200
249
|
if (sid === node.id)
|
|
201
250
|
continue;
|
|
202
|
-
const s =
|
|
251
|
+
const s = resolve(sid);
|
|
203
252
|
if (s && s.type === node.type)
|
|
204
253
|
siblings.push(s);
|
|
205
254
|
}
|
|
206
255
|
}
|
|
207
256
|
else {
|
|
208
|
-
siblings = Object.values(nodes)
|
|
257
|
+
siblings = Object.values(nodes)
|
|
258
|
+
.filter((n) => n !== node && n.type === node.type && n.parentId === parentId)
|
|
259
|
+
.map((n) => getEffectiveNode(n));
|
|
209
260
|
}
|
|
210
261
|
}
|
|
211
|
-
return { resolve, children, siblings, parent, levelData };
|
|
262
|
+
return { resolve, children, siblings, parent, levelData, materials };
|
|
212
263
|
}
|
|
213
264
|
function disposeChildren(group) {
|
|
214
265
|
// Only dispose meshes the geometry builder produced on the previous
|
|
@@ -277,3 +328,21 @@ function isCachedMaterial(value) {
|
|
|
277
328
|
?.__pascalCachedMaterial);
|
|
278
329
|
}
|
|
279
330
|
export default GeometrySystem;
|
|
331
|
+
export function markGeometryBuildOutput(child) {
|
|
332
|
+
// Tag the builder subtree so paint previews can reach nested meshes (for
|
|
333
|
+
// example a cabinet sink faucet handle), while disposal still removes only
|
|
334
|
+
// top-level builder children from the registered group.
|
|
335
|
+
child.traverse((object) => {
|
|
336
|
+
object.userData = {
|
|
337
|
+
...object.userData,
|
|
338
|
+
__fromGeometry: true,
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
export function shouldReuseGeometryBuild(cache, id, group, key) {
|
|
343
|
+
const cached = cache.get(id);
|
|
344
|
+
if (cached?.group === group && cached.key === key)
|
|
345
|
+
return true;
|
|
346
|
+
cache.set(id, { group, key });
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"geometry-system.test.d.ts","sourceRoot":"","sources":["../../../src/systems/geometry/geometry-system.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
|
2
|
+
// depend on @types/bun so the import type is unresolved at compile time.
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three';
|
|
5
|
+
import { markGeometryBuildOutput, shouldReuseGeometryBuild, } from './geometry-system';
|
|
6
|
+
describe('markGeometryBuildOutput', () => {
|
|
7
|
+
test('marks nested builder meshes for slot paint preview', () => {
|
|
8
|
+
const root = new Group();
|
|
9
|
+
const nested = new Group();
|
|
10
|
+
const mesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial());
|
|
11
|
+
mesh.userData.slotId = 'appliance';
|
|
12
|
+
nested.add(mesh);
|
|
13
|
+
root.add(nested);
|
|
14
|
+
markGeometryBuildOutput(root);
|
|
15
|
+
expect(root.userData.__fromGeometry).toBe(true);
|
|
16
|
+
expect(nested.userData.__fromGeometry).toBe(true);
|
|
17
|
+
expect(mesh.userData.__fromGeometry).toBe(true);
|
|
18
|
+
expect(mesh.userData.slotId).toBe('appliance');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('shouldReuseGeometryBuild', () => {
|
|
22
|
+
test('rebuilds when the same node id remounts into a new group with the same key', () => {
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
const firstGroup = new Group();
|
|
25
|
+
const remountedGroup = new Group();
|
|
26
|
+
expect(shouldReuseGeometryBuild(cache, 'duct_1', firstGroup, 'same-key')).toBe(false);
|
|
27
|
+
expect(shouldReuseGeometryBuild(cache, 'duct_1', firstGroup, 'same-key')).toBe(true);
|
|
28
|
+
expect(shouldReuseGeometryBuild(cache, 'duct_1', remountedGroup, 'same-key')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Control, ControlValue } from '@pascal-app/core';
|
|
2
|
+
/** One interactive control (toggle / slider / temperature) rendered inside the
|
|
3
|
+
* item controls overlay. Shared by the parametric `InteractiveSystem` and the
|
|
4
|
+
* baked-GLB `GlbInteractive` overlay so both look and behave identically. */
|
|
5
|
+
export declare const ControlWidget: ({ control, value, onChange, }: {
|
|
6
|
+
control: Control;
|
|
7
|
+
value: ControlValue;
|
|
8
|
+
onChange: (v: ControlValue) => void;
|
|
9
|
+
}) => import("react").JSX.Element | null;
|
|
10
|
+
//# sourceMappingURL=control-widget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"control-widget.d.ts","sourceRoot":"","sources":["../../../src/systems/interactive/control-widget.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAE7D;;8EAE8E;AAC9E,eAAO,MAAM,aAAa,GAAI,+BAI3B;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,YAAY,CAAA;IACnB,QAAQ,EAAE,CAAC,CAAC,EAAE,YAAY,KAAK,IAAI,CAAA;CACpC,uCAuEA,CAAA"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/** One interactive control (toggle / slider / temperature) rendered inside the
|
|
4
|
+
* item controls overlay. Shared by the parametric `InteractiveSystem` and the
|
|
5
|
+
* baked-GLB `GlbInteractive` overlay so both look and behave identically. */
|
|
6
|
+
export const ControlWidget = ({ control, value, onChange, }) => {
|
|
7
|
+
const labelStyle = {
|
|
8
|
+
color: 'white',
|
|
9
|
+
fontSize: 11,
|
|
10
|
+
fontFamily: 'monospace',
|
|
11
|
+
display: 'flex',
|
|
12
|
+
flexDirection: 'column',
|
|
13
|
+
gap: 2,
|
|
14
|
+
};
|
|
15
|
+
if (control.kind === 'toggle') {
|
|
16
|
+
return (_jsx("button", { onClick: () => onChange(!value), style: {
|
|
17
|
+
background: value ? '#4ade80' : '#374151',
|
|
18
|
+
color: 'white',
|
|
19
|
+
border: 'none',
|
|
20
|
+
borderRadius: 4,
|
|
21
|
+
padding: '4px 8px',
|
|
22
|
+
cursor: 'pointer',
|
|
23
|
+
fontSize: 12,
|
|
24
|
+
fontFamily: 'monospace',
|
|
25
|
+
transition: 'background 0.2s',
|
|
26
|
+
}, children: control.label ?? (value ? 'On' : 'Off') }));
|
|
27
|
+
}
|
|
28
|
+
if (control.kind === 'slider') {
|
|
29
|
+
return (_jsxs("label", { style: labelStyle, children: [_jsxs("span", { children: [control.label, ": ", value, control.unit ? ` ${control.unit}` : ''] }), _jsx("input", { max: control.max, min: control.min, onChange: (e) => onChange(Number(e.target.value)), onPointerDown: (e) => e.stopPropagation(), step: control.step, type: "range", value: value })] }));
|
|
30
|
+
}
|
|
31
|
+
if (control.kind === 'temperature') {
|
|
32
|
+
return (_jsxs("label", { style: labelStyle, children: [_jsxs("span", { children: [control.label, ": ", value, "\u00B0", control.unit] }), _jsx("input", { max: control.max, min: control.min, onChange: (e) => onChange(Number(e.target.value)), onPointerDown: (e) => e.stopPropagation(), step: 1, type: "range", value: value })] }));
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interactive-system.d.ts","sourceRoot":"","sources":["../../../src/systems/interactive/interactive-system.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"interactive-system.d.ts","sourceRoot":"","sources":["../../../src/systems/interactive/interactive-system.tsx"],"names":[],"mappings":"AAiCA,eAAO,MAAM,iBAAiB,mCAsB7B,CAAA"}
|
|
@@ -1,22 +1,41 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, Fragment as _Fragment
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { pointInPolygon, sceneRegistry, useInteractive, useScene, } from '@pascal-app/core';
|
|
4
4
|
import { Html } from '@react-three/drei';
|
|
5
5
|
import { createPortal, useFrame } from '@react-three/fiber';
|
|
6
|
-
import { useState } from 'react';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
7
|
import { Vector3 } from 'three';
|
|
8
8
|
import { useShallow } from 'zustand/react/shallow';
|
|
9
9
|
import useViewer from '../../store/use-viewer';
|
|
10
|
+
import { ControlWidget } from './control-widget';
|
|
10
11
|
const _tempVec = new Vector3();
|
|
11
|
-
// ---- Parent: one overlay per interactive item ----
|
|
12
|
+
// ---- Parent: one overlay per interactive item inside the selected zone ----
|
|
13
|
+
//
|
|
14
|
+
// The <Html> overlays only exist while a zone is selected and the item sits
|
|
15
|
+
// inside it. Mounting them unconditionally is not an option: each drei <Html>
|
|
16
|
+
// repositions and re-sorts its DOM element on every camera-move frame, and
|
|
17
|
+
// with `occlude` it also raycasts the entire scene per overlay per frame. On
|
|
18
|
+
// large scenes (hundreds of interactive items) that starves the frame budget
|
|
19
|
+
// and the display/z-index churn makes the whole DOM UI flicker.
|
|
20
|
+
//
|
|
21
|
+
// The child components stay rendered (returning null) so an overlay can fade
|
|
22
|
+
// out before its <Html> unmounts.
|
|
12
23
|
export const InteractiveSystem = () => {
|
|
24
|
+
const zoneId = useViewer((s) => s.selection.zoneId);
|
|
25
|
+
const zonePolygon = useScene((s) => {
|
|
26
|
+
if (!zoneId)
|
|
27
|
+
return null;
|
|
28
|
+
const z = s.nodes[zoneId];
|
|
29
|
+
return z?.polygon ?? null;
|
|
30
|
+
});
|
|
13
31
|
const interactiveNodeIds = useScene(useShallow((state) => Object.values(state.nodes)
|
|
14
32
|
.filter((n) => n.type === 'item' && n.asset.interactive != null)
|
|
15
33
|
.map((n) => n.id)));
|
|
16
|
-
return (_jsx(_Fragment, { children: interactiveNodeIds.map((id) => (_jsx(ItemControlsOverlay, { nodeId: id }, id))) }));
|
|
34
|
+
return (_jsx(_Fragment, { children: interactiveNodeIds.map((id) => (_jsx(ItemControlsOverlay, { nodeId: id, zonePolygon: zonePolygon }, id))) }));
|
|
17
35
|
};
|
|
18
36
|
// ---- Child: polls sceneRegistry then portals controls into the item group ----
|
|
19
|
-
const
|
|
37
|
+
const FADE_MS = 300;
|
|
38
|
+
const ItemControlsOverlay = ({ nodeId, zonePolygon, }) => {
|
|
20
39
|
const node = useScene((state) => state.nodes[nodeId]);
|
|
21
40
|
const [itemObj, setItemObj] = useState(null);
|
|
22
41
|
useFrame(() => {
|
|
@@ -28,26 +47,37 @@ const ItemControlsOverlay = ({ nodeId }) => {
|
|
|
28
47
|
});
|
|
29
48
|
const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues));
|
|
30
49
|
const setControlValue = useInteractive((state) => state.setControlValue);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
let visible = false;
|
|
51
|
+
if (itemObj && zonePolygon?.length) {
|
|
52
|
+
itemObj.getWorldPosition(_tempVec);
|
|
53
|
+
visible = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon);
|
|
54
|
+
}
|
|
55
|
+
// Fade in on mount and fade out before unmounting the <Html>.
|
|
56
|
+
const [mounted, setMounted] = useState(false);
|
|
57
|
+
const [shown, setShown] = useState(false);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (visible) {
|
|
60
|
+
setMounted(true);
|
|
61
|
+
// Double rAF: the overlay has to paint once at opacity 0 before the
|
|
62
|
+
// opacity-1 style lands, otherwise the fade-in transition is skipped.
|
|
63
|
+
let raf2 = 0;
|
|
64
|
+
const raf1 = requestAnimationFrame(() => {
|
|
65
|
+
raf2 = requestAnimationFrame(() => setShown(true));
|
|
66
|
+
});
|
|
67
|
+
return () => {
|
|
68
|
+
cancelAnimationFrame(raf1);
|
|
69
|
+
cancelAnimationFrame(raf2);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
setShown(false);
|
|
73
|
+
const timeout = setTimeout(() => setMounted(false), FADE_MS);
|
|
74
|
+
return () => clearTimeout(timeout);
|
|
75
|
+
}, [visible]);
|
|
76
|
+
if (!(mounted && itemObj && controlValues && node?.asset.interactive))
|
|
39
77
|
return null;
|
|
40
78
|
const { controls } = node.asset.interactive;
|
|
41
79
|
const [, height] = node.asset.dimensions;
|
|
42
|
-
|
|
43
|
-
let pointerEvents = 'none';
|
|
44
|
-
if (zoneId && zonePolygon?.length) {
|
|
45
|
-
itemObj.getWorldPosition(_tempVec);
|
|
46
|
-
const inside = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon);
|
|
47
|
-
opacity = inside ? 1 : 0.1;
|
|
48
|
-
pointerEvents = inside ? 'auto' : 'none';
|
|
49
|
-
}
|
|
50
|
-
return createPortal(_jsx(Html, { center: true, distanceFactor: 8, occlude: true, position: [0, height + 0.3, 0], zIndexRange: [20, 0], children: _jsx("div", { style: {
|
|
80
|
+
return createPortal(_jsx(Html, { center: true, distanceFactor: 8, eps: -1, position: [0, height + 0.3, 0], zIndexRange: [20, 0], children: _jsx("div", { style: {
|
|
51
81
|
display: 'flex',
|
|
52
82
|
flexDirection: 'column',
|
|
53
83
|
gap: 6,
|
|
@@ -56,40 +86,9 @@ const ItemControlsOverlay = ({ nodeId }) => {
|
|
|
56
86
|
borderRadius: 8,
|
|
57
87
|
padding: '8px 12px',
|
|
58
88
|
minWidth: 120,
|
|
59
|
-
pointerEvents,
|
|
89
|
+
pointerEvents: visible ? 'auto' : 'none',
|
|
60
90
|
userSelect: 'none',
|
|
61
|
-
opacity,
|
|
62
|
-
transition:
|
|
91
|
+
opacity: shown ? 1 : 0,
|
|
92
|
+
transition: `opacity ${FADE_MS}ms ease`,
|
|
63
93
|
}, children: controls.map((control, i) => (_jsx(ControlWidget, { control: control, onChange: (v) => setControlValue(nodeId, i, v), value: controlValues[i] ?? false }, i))) }) }), itemObj);
|
|
64
94
|
};
|
|
65
|
-
// ---- Control widgets ----
|
|
66
|
-
const ControlWidget = ({ control, value, onChange, }) => {
|
|
67
|
-
const labelStyle = {
|
|
68
|
-
color: 'white',
|
|
69
|
-
fontSize: 11,
|
|
70
|
-
fontFamily: 'monospace',
|
|
71
|
-
display: 'flex',
|
|
72
|
-
flexDirection: 'column',
|
|
73
|
-
gap: 2,
|
|
74
|
-
};
|
|
75
|
-
if (control.kind === 'toggle') {
|
|
76
|
-
return (_jsx("button", { onClick: () => onChange(!value), style: {
|
|
77
|
-
background: value ? '#4ade80' : '#374151',
|
|
78
|
-
color: 'white',
|
|
79
|
-
border: 'none',
|
|
80
|
-
borderRadius: 4,
|
|
81
|
-
padding: '4px 8px',
|
|
82
|
-
cursor: 'pointer',
|
|
83
|
-
fontSize: 12,
|
|
84
|
-
fontFamily: 'monospace',
|
|
85
|
-
transition: 'background 0.2s',
|
|
86
|
-
}, children: control.label ?? (value ? 'On' : 'Off') }));
|
|
87
|
-
}
|
|
88
|
-
if (control.kind === 'slider') {
|
|
89
|
-
return (_jsxs("label", { style: labelStyle, children: [_jsxs("span", { children: [control.label, ": ", value, control.unit ? ` ${control.unit}` : ''] }), _jsx("input", { max: control.max, min: control.min, onChange: (e) => onChange(Number(e.target.value)), onPointerDown: (e) => e.stopPropagation(), step: control.step, type: "range", value: value })] }));
|
|
90
|
-
}
|
|
91
|
-
if (control.kind === 'temperature') {
|
|
92
|
-
return (_jsxs("label", { style: labelStyle, children: [_jsxs("span", { children: [control.label, ": ", value, "\u00B0", control.unit] }), _jsx("input", { max: control.max, min: control.min, onChange: (e) => onChange(Number(e.target.value)), onPointerDown: (e) => e.stopPropagation(), step: 1, type: "range", value: value })] }));
|
|
93
|
-
}
|
|
94
|
-
return null;
|
|
95
|
-
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"item-system.d.ts","sourceRoot":"","sources":["../../../src/systems/item/item-system.tsx"],"names":[],"mappings":"AAcA;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,
|
|
1
|
+
{"version":3,"file":"item-system.d.ts","sourceRoot":"","sources":["../../../src/systems/item/item-system.tsx"],"names":[],"mappings":"AAcA;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,YA8CtB,CAAA"}
|
|
@@ -19,21 +19,35 @@ export const ItemSystem = () => {
|
|
|
19
19
|
const nodes = useScene.getState().nodes;
|
|
20
20
|
dirtyNodes.forEach((id) => {
|
|
21
21
|
const node = nodes[id];
|
|
22
|
-
if (
|
|
22
|
+
if (node?.type !== 'item')
|
|
23
23
|
return;
|
|
24
24
|
const item = node;
|
|
25
25
|
const mesh = sceneRegistry.nodes.get(id);
|
|
26
26
|
if (!mesh)
|
|
27
27
|
return;
|
|
28
28
|
if (item.asset.attachTo === 'wall-side') {
|
|
29
|
-
// Wall-attached item: offset Z by half the
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
// Wall-attached item: offset Z by half the host wall's thickness.
|
|
30
|
+
// Roof-segment wall faces share the convention — the face frame's
|
|
31
|
+
// z = 0 is the wall mid-plane, so the same push lands the item on
|
|
32
|
+
// the outer surface.
|
|
33
|
+
const parent = item.parentId ? nodes[item.parentId] : undefined;
|
|
34
|
+
const thickness = parent?.type === 'wall'
|
|
35
|
+
? (parent.thickness ?? 0.1)
|
|
36
|
+
: parent?.type === 'roof-segment'
|
|
37
|
+
? (parent.wallThickness ?? 0.1)
|
|
38
|
+
: undefined;
|
|
39
|
+
if (thickness !== undefined) {
|
|
33
40
|
const side = item.side === 'front' ? 1 : -1;
|
|
34
|
-
mesh.position.z = (
|
|
41
|
+
mesh.position.z = (thickness / 2) * side;
|
|
35
42
|
}
|
|
36
43
|
}
|
|
44
|
+
// Hold the mark until the model settles (loaded, terminally failed, or
|
|
45
|
+
// never expected — see ItemRenderer.markSettled). Registration alone
|
|
46
|
+
// isn't "built": clearing then would let scene-ready fire while GLBs are
|
|
47
|
+
// still downloading and a bake would export loading placeholders.
|
|
48
|
+
const settled = mesh.userData.itemModelSettled;
|
|
49
|
+
if (!settled)
|
|
50
|
+
return;
|
|
37
51
|
clearDirty(id);
|
|
38
52
|
});
|
|
39
53
|
}, 2);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"level-system.d.ts","sourceRoot":"","sources":["../../../src/systems/level/level-system.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"level-system.d.ts","sourceRoot":"","sources":["../../../src/systems/level/level-system.tsx"],"names":[],"mappings":"AAeA,eAAO,MAAM,WAAW,YAuDvB,CAAA"}
|
|
@@ -1,33 +1,58 @@
|
|
|
1
|
-
import { sceneRegistry, useScene } from '@pascal-app/core';
|
|
1
|
+
import { getLevelElevations, sceneRegistry, useScene } from '@pascal-app/core';
|
|
2
2
|
import { useFrame } from '@react-three/fiber';
|
|
3
3
|
import { lerp } from 'three/src/math/MathUtils.js';
|
|
4
|
+
import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only';
|
|
4
5
|
import useViewer from '../../store/use-viewer';
|
|
5
|
-
import { getLevelHeight } from './level-utils';
|
|
6
6
|
const EXPLODED_GAP = 5;
|
|
7
|
+
// Levels currently in shadow-caster-only mode (solo hides them from the color
|
|
8
|
+
// passes but keeps their sun shadows). Tracked so we can restore layer masks
|
|
9
|
+
// exactly once on transition; apply re-runs every frame so meshes rebuilt
|
|
10
|
+
// while hidden (theme/texture changes) get re-hidden.
|
|
11
|
+
const shadowOnlyLevels = new WeakSet();
|
|
7
12
|
export const LevelSystem = () => {
|
|
8
13
|
useFrame((_, delta) => {
|
|
9
14
|
const nodes = useScene.getState().nodes;
|
|
10
15
|
const levelMode = useViewer.getState().levelMode;
|
|
11
16
|
const selectedLevel = useViewer.getState().selection.levelId;
|
|
17
|
+
const levelElevations = getLevelElevations(nodes);
|
|
12
18
|
const entries = [];
|
|
13
19
|
sceneRegistry.byType.level.forEach((levelId) => {
|
|
14
20
|
const obj = sceneRegistry.nodes.get(levelId);
|
|
15
21
|
const level = nodes[levelId];
|
|
16
22
|
if (obj && level) {
|
|
17
|
-
entries.push({
|
|
23
|
+
entries.push({
|
|
24
|
+
levelId,
|
|
25
|
+
index: level.level,
|
|
26
|
+
obj,
|
|
27
|
+
});
|
|
18
28
|
}
|
|
19
29
|
});
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
const selectedIndex = selectedLevel
|
|
31
|
+
? entries.find((e) => e.levelId === selectedLevel)?.index
|
|
32
|
+
: undefined;
|
|
23
33
|
for (const { levelId, index, obj } of entries) {
|
|
24
34
|
const level = nodes[levelId];
|
|
25
|
-
const baseY =
|
|
35
|
+
const baseY = levelElevations.get(levelId)?.baseY ?? 0;
|
|
26
36
|
const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0;
|
|
27
37
|
const targetY = baseY + explodedExtra;
|
|
28
38
|
obj.position.y = lerp(obj.position.y, targetY, delta * 12); // Smoothly animate to new Y position
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
// Solo: hidden levels ABOVE the soloed one stay in the shadow map
|
|
40
|
+
// (shadow-caster-only) so the sun still shadows the soloed floor through
|
|
41
|
+
// them; levels below can't block the sun, so they plain-hide.
|
|
42
|
+
const hidden = levelMode === 'solo' && Boolean(selectedLevel) && level?.id !== selectedLevel;
|
|
43
|
+
const castsWhileHidden = hidden && selectedIndex !== undefined && index > selectedIndex;
|
|
44
|
+
if (castsWhileHidden) {
|
|
45
|
+
applyShadowOnly(obj);
|
|
46
|
+
shadowOnlyLevels.add(obj);
|
|
47
|
+
obj.visible = true;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
if (shadowOnlyLevels.has(obj)) {
|
|
51
|
+
clearShadowOnly(obj);
|
|
52
|
+
shadowOnlyLevels.delete(obj);
|
|
53
|
+
}
|
|
54
|
+
obj.visible = !hidden;
|
|
55
|
+
}
|
|
31
56
|
}
|
|
32
57
|
}, 5); // Using a lower priority so it runs after transforms from other systems have settled
|
|
33
58
|
return null;
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { useScene } from '@pascal-app/core';
|
|
2
|
-
export declare const DEFAULT_LEVEL_HEIGHT = 2.5;
|
|
3
|
-
export declare function getLevelHeight(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']): number;
|
|
4
1
|
/**
|
|
5
2
|
* Instantly snaps all level Objects3D to their true stacked Y positions
|
|
6
3
|
* (ignores levelMode — always uses stacked, no exploded gap).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"level-utils.d.ts","sourceRoot":"","sources":["../../../src/systems/level/level-utils.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"level-utils.d.ts","sourceRoot":"","sources":["../../../src/systems/level/level-utils.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,IAAI,MAAM,IAAI,CAyCtD"}
|
|
@@ -1,43 +1,4 @@
|
|
|
1
|
-
import { sceneRegistry, useScene
|
|
2
|
-
export const DEFAULT_LEVEL_HEIGHT = 2.5;
|
|
3
|
-
// Cache: levelId → computed height. Invalidated when the nodes reference changes.
|
|
4
|
-
// Zustand produces a new `nodes` object on every mutation, so reference equality
|
|
5
|
-
// is a zero-cost way to detect stale data without any subscription overhead.
|
|
6
|
-
const heightCache = new Map();
|
|
7
|
-
let lastNodesRef = null;
|
|
8
|
-
export function getLevelHeight(levelId, nodes) {
|
|
9
|
-
if (nodes !== lastNodesRef) {
|
|
10
|
-
heightCache.clear();
|
|
11
|
-
lastNodesRef = nodes;
|
|
12
|
-
}
|
|
13
|
-
if (heightCache.has(levelId))
|
|
14
|
-
return heightCache.get(levelId);
|
|
15
|
-
const level = nodes[levelId];
|
|
16
|
-
if (!level)
|
|
17
|
-
return DEFAULT_LEVEL_HEIGHT;
|
|
18
|
-
let maxTop = 0;
|
|
19
|
-
for (const childId of level.children) {
|
|
20
|
-
const child = nodes[childId];
|
|
21
|
-
if (!child)
|
|
22
|
-
continue;
|
|
23
|
-
if (child.type === 'ceiling') {
|
|
24
|
-
const ch = child.height ?? DEFAULT_LEVEL_HEIGHT;
|
|
25
|
-
if (ch > maxTop)
|
|
26
|
-
maxTop = ch;
|
|
27
|
-
}
|
|
28
|
-
else if (child.type === 'wall') {
|
|
29
|
-
let meshY = sceneRegistry.nodes.get(childId)?.position.y ?? 0;
|
|
30
|
-
if (meshY < 0)
|
|
31
|
-
meshY = 0;
|
|
32
|
-
const top = meshY + (child.height ?? DEFAULT_LEVEL_HEIGHT);
|
|
33
|
-
if (top > maxTop)
|
|
34
|
-
maxTop = top;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT;
|
|
38
|
-
heightCache.set(levelId, height);
|
|
39
|
-
return height;
|
|
40
|
-
}
|
|
1
|
+
import { getLevelElevations, sceneRegistry, useScene } from '@pascal-app/core';
|
|
41
2
|
/**
|
|
42
3
|
* Instantly snaps all level Objects3D to their true stacked Y positions
|
|
43
4
|
* (ignores levelMode — always uses stacked, no exploded gap).
|
|
@@ -57,18 +18,19 @@ export function snapLevelsToTruePositions() {
|
|
|
57
18
|
const obj = sceneRegistry.nodes.get(levelId);
|
|
58
19
|
const level = nodes[levelId];
|
|
59
20
|
if (obj && level) {
|
|
60
|
-
entries.push({
|
|
21
|
+
entries.push({
|
|
22
|
+
levelId,
|
|
23
|
+
obj,
|
|
24
|
+
});
|
|
61
25
|
}
|
|
62
26
|
});
|
|
63
|
-
|
|
27
|
+
const levelElevations = getLevelElevations(nodes);
|
|
64
28
|
// Snapshot current Y and visibility so we can restore them after the render
|
|
65
29
|
const snapshot = new Map(entries.map(({ levelId, obj }) => [levelId, { y: obj.position.y, visible: obj.visible }]));
|
|
66
30
|
// Snap to true stacked positions and make all levels visible
|
|
67
|
-
let cumulativeY = 0;
|
|
68
31
|
for (const { levelId, obj } of entries) {
|
|
69
|
-
obj.position.y =
|
|
32
|
+
obj.position.y = levelElevations.get(levelId)?.baseY ?? 0;
|
|
70
33
|
obj.visible = true;
|
|
71
|
-
cumulativeY += getLevelHeight(levelId, nodes);
|
|
72
34
|
}
|
|
73
35
|
return () => {
|
|
74
36
|
for (const { levelId, obj } of entries) {
|