@pascal-app/viewer 0.1.13 → 0.3.0
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/error-boundary.d.ts +18 -0
- package/dist/components/error-boundary.d.ts.map +1 -0
- package/dist/components/error-boundary.js +11 -0
- package/dist/components/renderers/ceiling/ceiling-renderer.d.ts.map +1 -1
- package/dist/components/renderers/ceiling/ceiling-renderer.js +16 -9
- package/dist/components/renderers/door/door-renderer.d.ts +5 -0
- package/dist/components/renderers/door/door-renderer.d.ts.map +1 -0
- package/dist/components/renderers/door/door-renderer.js +11 -0
- package/dist/components/renderers/guide/guide-renderer.d.ts.map +1 -1
- package/dist/components/renderers/guide/guide-renderer.js +4 -2
- package/dist/components/renderers/item/item-renderer.d.ts.map +1 -1
- package/dist/components/renderers/item/item-renderer.js +98 -7
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +3 -1
- package/dist/components/renderers/roof/roof-materials.d.ts +4 -0
- package/dist/components/renderers/roof/roof-materials.d.ts.map +1 -0
- package/dist/components/renderers/roof/roof-materials.js +16 -0
- package/dist/components/renderers/roof/roof-renderer.d.ts.map +1 -1
- package/dist/components/renderers/roof/roof-renderer.js +5 -1
- package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts +5 -0
- package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts.map +1 -0
- package/dist/components/renderers/roof-segment/roof-segment-renderer.js +13 -0
- package/dist/components/renderers/scan/scan-renderer.d.ts.map +1 -1
- package/dist/components/renderers/scan/scan-renderer.js +3 -1
- package/dist/components/renderers/scene-renderer.d.ts.map +1 -1
- package/dist/components/renderers/scene-renderer.js +3 -3
- package/dist/components/renderers/site/site-renderer.d.ts.map +1 -1
- package/dist/components/renderers/site/site-renderer.js +4 -19
- package/dist/components/renderers/slab/slab-renderer.js +1 -1
- package/dist/components/renderers/wall/wall-renderer.d.ts.map +1 -1
- package/dist/components/renderers/wall/wall-renderer.js +7 -3
- package/dist/components/renderers/window/window-renderer.d.ts.map +1 -1
- package/dist/components/renderers/window/window-renderer.js +2 -1
- package/dist/components/renderers/zone/zone-renderer.d.ts.map +1 -1
- package/dist/components/renderers/zone/zone-renderer.js +33 -13
- package/dist/components/viewer/ground-occluder.d.ts +2 -0
- package/dist/components/viewer/ground-occluder.d.ts.map +1 -0
- package/dist/components/viewer/ground-occluder.js +75 -0
- package/dist/components/viewer/index.d.ts +1 -0
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +59 -6
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +69 -5
- package/dist/components/viewer/perf-monitor.d.ts +2 -0
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -0
- package/dist/components/viewer/perf-monitor.js +42 -0
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +230 -107
- package/dist/components/viewer/selection-manager.d.ts.map +1 -1
- package/dist/components/viewer/selection-manager.js +47 -17
- package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
- package/dist/components/viewer/viewer-camera.js +2 -2
- package/dist/hooks/use-gltf-ktx2.d.ts +1 -1
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +25 -7
- package/dist/hooks/use-grid-events.d.ts +12 -0
- package/dist/hooks/use-grid-events.d.ts.map +1 -0
- package/dist/hooks/use-grid-events.js +33 -0
- package/dist/hooks/use-node-events.d.ts +9 -1
- package/dist/hooks/use-node-events.d.ts.map +1 -1
- package/dist/hooks/use-node-events.js +5 -5
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/lib/asset-url.d.ts +1 -1
- package/dist/lib/asset-url.d.ts.map +1 -1
- package/dist/lib/asset-url.js +1 -1
- package/dist/lib/layers.d.ts +5 -0
- package/dist/lib/layers.d.ts.map +1 -0
- package/dist/lib/layers.js +4 -0
- package/dist/store/use-item-light-pool.d.ts +18 -0
- package/dist/store/use-item-light-pool.d.ts.map +1 -0
- package/dist/store/use-item-light-pool.js +30 -0
- package/dist/store/use-viewer.d.ts +56 -7
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +82 -17
- package/dist/systems/interactive/interactive-system.d.ts +2 -0
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -0
- package/dist/systems/interactive/interactive-system.js +95 -0
- package/dist/systems/item-light/item-light-system.d.ts +2 -0
- package/dist/systems/item-light/item-light-system.d.ts.map +1 -0
- package/dist/systems/item-light/item-light-system.js +235 -0
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +19 -8
- package/dist/systems/level/level-utils.d.ts +17 -0
- package/dist/systems/level/level-utils.d.ts.map +1 -0
- package/dist/systems/level/level-utils.js +82 -0
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +2 -4
- package/dist/systems/zone/zone-system.d.ts.map +1 -1
- package/dist/systems/zone/zone-system.js +29 -3
- package/package.json +7 -5
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { sceneRegistry, useInteractive, useScene } from '@pascal-app/core';
|
|
3
|
+
import { useFrame } from '@react-three/fiber';
|
|
4
|
+
import { useRef } from 'react';
|
|
5
|
+
import { MathUtils, Vector3 } from 'three';
|
|
6
|
+
import { useItemLightPool } from '../../store/use-item-light-pool';
|
|
7
|
+
import useViewer from '../../store/use-viewer';
|
|
8
|
+
const POOL_SIZE = 12;
|
|
9
|
+
// How often (in seconds) to re-evaluate which items have lights assigned (fallback timer)
|
|
10
|
+
const REASSIGN_INTERVAL = 0.2;
|
|
11
|
+
// Hysteresis: a currently-assigned slot keeps its key unless an unassigned
|
|
12
|
+
// candidate beats it by at least this much (prevents flickering at the boundary)
|
|
13
|
+
const HYSTERESIS = 0.15;
|
|
14
|
+
// Camera movement thresholds that trigger an early re-evaluation
|
|
15
|
+
const CAM_MOVE_DIST = 0.5; // units
|
|
16
|
+
const CAM_ROT_DOT = 0.995; // cos(~5.7°)
|
|
17
|
+
// Module-level temp vectors reused every frame (avoids GC pressure)
|
|
18
|
+
const _dir = new Vector3();
|
|
19
|
+
const _camPos = new Vector3();
|
|
20
|
+
const _camFwd = new Vector3();
|
|
21
|
+
const _itemPos = new Vector3();
|
|
22
|
+
function scoreRegistration(reg, nodes, selectedLevelId, levelMode, interactiveState) {
|
|
23
|
+
// Skip lights that are toggled off — they contribute no illumination
|
|
24
|
+
if (reg.toggleIndex >= 0) {
|
|
25
|
+
const values = interactiveState.items[reg.nodeId]?.controlValues;
|
|
26
|
+
const isOn = Boolean(values?.[reg.toggleIndex]);
|
|
27
|
+
if (!isOn)
|
|
28
|
+
return Number.POSITIVE_INFINITY;
|
|
29
|
+
}
|
|
30
|
+
const { nodeId, effect } = reg;
|
|
31
|
+
const obj = sceneRegistry.nodes.get(nodeId);
|
|
32
|
+
if (!obj)
|
|
33
|
+
return Number.POSITIVE_INFINITY;
|
|
34
|
+
obj.getWorldPosition(_itemPos);
|
|
35
|
+
_itemPos.x += effect.offset[0];
|
|
36
|
+
_itemPos.y += effect.offset[1];
|
|
37
|
+
_itemPos.z += effect.offset[2];
|
|
38
|
+
_dir.copy(_itemPos).sub(_camPos).normalize();
|
|
39
|
+
const dot = _camFwd.dot(_dir); // 1 = ahead, -1 = behind
|
|
40
|
+
// Angular component (0 = dead ahead, 2 = directly behind)
|
|
41
|
+
const angular = 1 - dot;
|
|
42
|
+
// Normalised distance component (assumes scenes < 200 units)
|
|
43
|
+
const dist = _camPos.distanceTo(_itemPos) / 200;
|
|
44
|
+
// ── Level factor ──────────────────────────────────────────────────────────
|
|
45
|
+
const node = nodes[nodeId];
|
|
46
|
+
const itemLevelId = node?.parentId ?? null;
|
|
47
|
+
let levelPenalty = 0;
|
|
48
|
+
if (selectedLevelId) {
|
|
49
|
+
if (itemLevelId !== selectedLevelId) {
|
|
50
|
+
// In solo mode items on other levels are invisible — deprioritize strongly
|
|
51
|
+
levelPenalty = levelMode === 'solo' ? 100 : 0.8;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else if (itemLevelId) {
|
|
55
|
+
// No level selected — lightly prefer items on level index 0
|
|
56
|
+
const levelNode = nodes[itemLevelId];
|
|
57
|
+
const levelIndex = levelNode?.level ?? 0;
|
|
58
|
+
if (levelIndex !== 0)
|
|
59
|
+
levelPenalty = 0.3;
|
|
60
|
+
}
|
|
61
|
+
return angular * 0.7 + dist * 0.3 + levelPenalty;
|
|
62
|
+
}
|
|
63
|
+
export function ItemLightSystem() {
|
|
64
|
+
const lightRefs = useRef(Array.from({ length: POOL_SIZE }, () => null));
|
|
65
|
+
const slots = useRef(Array.from({ length: POOL_SIZE }, () => ({ key: null, pendingKey: null, isFadingOut: false })));
|
|
66
|
+
const reassignTimer = useRef(0);
|
|
67
|
+
// Track camera state at last reassignment to detect meaningful movement
|
|
68
|
+
const prevReassignCamPos = useRef(new Vector3());
|
|
69
|
+
const prevReassignCamFwd = useRef(new Vector3(0, 0, -1));
|
|
70
|
+
useFrame(({ camera }, delta) => {
|
|
71
|
+
const dt = Math.min(delta, 0.1);
|
|
72
|
+
const { registrations } = useItemLightPool.getState();
|
|
73
|
+
const interactiveState = useInteractive.getState();
|
|
74
|
+
// ── 1. Throttled priority reassignment ──────────────────────────────────
|
|
75
|
+
camera.getWorldPosition(_camPos);
|
|
76
|
+
camera.getWorldDirection(_camFwd);
|
|
77
|
+
const camMoved = _camPos.distanceTo(prevReassignCamPos.current) > CAM_MOVE_DIST ||
|
|
78
|
+
_camFwd.dot(prevReassignCamFwd.current) < CAM_ROT_DOT;
|
|
79
|
+
reassignTimer.current -= delta;
|
|
80
|
+
const shouldReassign = reassignTimer.current <= 0 || camMoved;
|
|
81
|
+
if (shouldReassign) {
|
|
82
|
+
reassignTimer.current = REASSIGN_INTERVAL;
|
|
83
|
+
prevReassignCamPos.current.copy(_camPos);
|
|
84
|
+
prevReassignCamFwd.current.copy(_camFwd);
|
|
85
|
+
// Read level/scene state once for the whole tick
|
|
86
|
+
const nodes = useScene.getState().nodes;
|
|
87
|
+
const viewerState = useViewer.getState();
|
|
88
|
+
const selectedLevelId = viewerState.selection.levelId;
|
|
89
|
+
const levelMode = viewerState.levelMode;
|
|
90
|
+
// Score every registration
|
|
91
|
+
const scored = [];
|
|
92
|
+
for (const [key, reg] of registrations) {
|
|
93
|
+
scored.push({
|
|
94
|
+
key,
|
|
95
|
+
score: scoreRegistration(reg, nodes, selectedLevelId, levelMode, interactiveState),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
scored.sort((a, b) => a.score - b.score);
|
|
99
|
+
// Build the desired assignment (top POOL_SIZE keys)
|
|
100
|
+
const desired = scored.slice(0, POOL_SIZE).map((s) => s.key);
|
|
101
|
+
// Build a map of currently-assigned keys → slot index for hysteresis
|
|
102
|
+
const currentlyAssigned = new Map();
|
|
103
|
+
for (let i = 0; i < POOL_SIZE; i++) {
|
|
104
|
+
const s = slots.current[i];
|
|
105
|
+
if (!s)
|
|
106
|
+
continue;
|
|
107
|
+
const k = s.key ?? s.pendingKey;
|
|
108
|
+
if (k)
|
|
109
|
+
currentlyAssigned.set(k, i);
|
|
110
|
+
}
|
|
111
|
+
// Assign desired keys to slots — prefer keeping existing assignments
|
|
112
|
+
const usedSlots = new Set();
|
|
113
|
+
const assignedKeys = new Set();
|
|
114
|
+
// Pass 1: keep existing slots where the key is still in desired
|
|
115
|
+
for (const key of desired) {
|
|
116
|
+
const existingSlot = currentlyAssigned.get(key);
|
|
117
|
+
if (existingSlot !== undefined && !usedSlots.has(existingSlot)) {
|
|
118
|
+
usedSlots.add(existingSlot);
|
|
119
|
+
assignedKeys.add(key);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Pass 2: assign remaining desired keys to free slots
|
|
123
|
+
let freeSlot = 0;
|
|
124
|
+
for (const key of desired) {
|
|
125
|
+
if (assignedKeys.has(key))
|
|
126
|
+
continue;
|
|
127
|
+
while (freeSlot < POOL_SIZE && usedSlots.has(freeSlot))
|
|
128
|
+
freeSlot++;
|
|
129
|
+
if (freeSlot >= POOL_SIZE)
|
|
130
|
+
break;
|
|
131
|
+
// Hysteresis: only evict the current occupant if the new key scores
|
|
132
|
+
// meaningfully better than it
|
|
133
|
+
const freeSlotData = slots.current[freeSlot];
|
|
134
|
+
const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null;
|
|
135
|
+
if (currentKey && !desired.includes(currentKey)) {
|
|
136
|
+
const currentScore = scored.find((s) => s.key === currentKey)?.score ?? Number.POSITIVE_INFINITY;
|
|
137
|
+
const newScore = scored.find((s) => s.key === key)?.score ?? 0;
|
|
138
|
+
if (currentScore - newScore < HYSTERESIS) {
|
|
139
|
+
freeSlot++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
usedSlots.add(freeSlot);
|
|
144
|
+
assignedKeys.add(key);
|
|
145
|
+
const slot = slots.current[freeSlot];
|
|
146
|
+
if (slot && slot.key !== key) {
|
|
147
|
+
slot.pendingKey = key;
|
|
148
|
+
slot.isFadingOut = slot.key !== null;
|
|
149
|
+
if (!slot.isFadingOut) {
|
|
150
|
+
// Slot was idle — skip fade-out, assign immediately
|
|
151
|
+
slot.key = key;
|
|
152
|
+
slot.pendingKey = null;
|
|
153
|
+
const light = lightRefs.current[freeSlot];
|
|
154
|
+
const reg = registrations.get(key);
|
|
155
|
+
if (light && reg) {
|
|
156
|
+
light.color.set(reg.effect.color);
|
|
157
|
+
light.distance = reg.effect.distance ?? 0;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
freeSlot++;
|
|
162
|
+
}
|
|
163
|
+
// Clear slots whose key is no longer in desired and not pending
|
|
164
|
+
for (let i = 0; i < POOL_SIZE; i++) {
|
|
165
|
+
if (!usedSlots.has(i)) {
|
|
166
|
+
const slot = slots.current[i];
|
|
167
|
+
if (slot?.key && !desired.includes(slot.key)) {
|
|
168
|
+
slot.pendingKey = null;
|
|
169
|
+
slot.isFadingOut = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// ── 2. Per-frame light updates ───────────────────────────────────────────
|
|
175
|
+
for (let i = 0; i < POOL_SIZE; i++) {
|
|
176
|
+
const light = lightRefs.current[i];
|
|
177
|
+
if (!light)
|
|
178
|
+
continue;
|
|
179
|
+
const slot = slots.current[i];
|
|
180
|
+
if (!slot)
|
|
181
|
+
continue;
|
|
182
|
+
// Fade-out phase: lerp intensity → 0, then complete the transition
|
|
183
|
+
if (slot.isFadingOut) {
|
|
184
|
+
light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12);
|
|
185
|
+
if (light.intensity < 0.01) {
|
|
186
|
+
light.intensity = 0;
|
|
187
|
+
slot.isFadingOut = false;
|
|
188
|
+
slot.key = slot.pendingKey;
|
|
189
|
+
slot.pendingKey = null;
|
|
190
|
+
if (slot.key) {
|
|
191
|
+
const reg = registrations.get(slot.key);
|
|
192
|
+
if (reg) {
|
|
193
|
+
light.color.set(reg.effect.color);
|
|
194
|
+
light.distance = reg.effect.distance ?? 0;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!slot.key) {
|
|
201
|
+
// Idle slot — keep dark
|
|
202
|
+
light.intensity = 0;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const reg = registrations.get(slot.key);
|
|
206
|
+
if (!reg) {
|
|
207
|
+
slot.key = null;
|
|
208
|
+
light.intensity = 0;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// Snap world position each frame
|
|
212
|
+
const obj = sceneRegistry.nodes.get(reg.nodeId);
|
|
213
|
+
if (obj) {
|
|
214
|
+
obj.getWorldPosition(_itemPos);
|
|
215
|
+
const [ox, oy, oz] = reg.effect.offset;
|
|
216
|
+
light.position.set(_itemPos.x + ox, _itemPos.y + oy, _itemPos.z + oz);
|
|
217
|
+
}
|
|
218
|
+
// Compute target intensity
|
|
219
|
+
const values = interactiveState.items[reg.nodeId]?.controlValues;
|
|
220
|
+
const isOn = reg.toggleIndex >= 0 ? Boolean(values?.[reg.toggleIndex]) : true;
|
|
221
|
+
let t = 1;
|
|
222
|
+
if (reg.hasSlider) {
|
|
223
|
+
const raw = values?.[reg.sliderIndex] ?? reg.sliderMin;
|
|
224
|
+
t = (raw - reg.sliderMin) / (reg.sliderMax - reg.sliderMin);
|
|
225
|
+
}
|
|
226
|
+
const targetIntensity = isOn
|
|
227
|
+
? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t)
|
|
228
|
+
: reg.effect.intensityRange[0];
|
|
229
|
+
light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
return (_jsx(_Fragment, { children: Array.from({ length: POOL_SIZE }, (_, i) => (_jsx("pointLight", { castShadow: false, intensity: 0, ref: (el) => {
|
|
233
|
+
lightRefs.current[i] = el;
|
|
234
|
+
} }, i))) }));
|
|
235
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"level-system.d.ts","sourceRoot":"","sources":["../../../src/systems/level/level-system.tsx"],"names":[],"mappings":"AAQA,eAAO,MAAM,WAAW,
|
|
1
|
+
{"version":3,"file":"level-system.d.ts","sourceRoot":"","sources":["../../../src/systems/level/level-system.tsx"],"names":[],"mappings":"AAQA,eAAO,MAAM,WAAW,YAsCvB,CAAA"}
|
|
@@ -2,22 +2,33 @@ import { 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
4
|
import useViewer from '../../store/use-viewer';
|
|
5
|
-
|
|
5
|
+
import { getLevelHeight } from './level-utils';
|
|
6
6
|
const EXPLODED_GAP = 5;
|
|
7
7
|
export const LevelSystem = () => {
|
|
8
8
|
useFrame((_, delta) => {
|
|
9
|
+
const nodes = useScene.getState().nodes;
|
|
9
10
|
const levelMode = useViewer.getState().levelMode;
|
|
10
11
|
const selectedLevel = useViewer.getState().selection.levelId;
|
|
12
|
+
const entries = [];
|
|
11
13
|
sceneRegistry.byType.level.forEach((levelId) => {
|
|
12
14
|
const obj = sceneRegistry.nodes.get(levelId);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
(LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0));
|
|
17
|
-
obj.position.y = lerp(obj.position.y, targetY, delta * 3);
|
|
18
|
-
obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel;
|
|
15
|
+
const level = nodes[levelId];
|
|
16
|
+
if (obj && level) {
|
|
17
|
+
entries.push({ levelId, index: level.level ?? 0, obj });
|
|
19
18
|
}
|
|
20
19
|
});
|
|
21
|
-
|
|
20
|
+
entries.sort((a, b) => a.index - b.index);
|
|
21
|
+
// Walk sorted levels, accumulating base Y offsets
|
|
22
|
+
let cumulativeY = 0;
|
|
23
|
+
for (const { levelId, index, obj } of entries) {
|
|
24
|
+
const level = nodes[levelId];
|
|
25
|
+
const baseY = cumulativeY;
|
|
26
|
+
const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0;
|
|
27
|
+
const targetY = baseY + explodedExtra;
|
|
28
|
+
obj.position.y = lerp(obj.position.y, targetY, delta * 12); // Smoothly animate to new Y position
|
|
29
|
+
obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel;
|
|
30
|
+
cumulativeY += getLevelHeight(levelId, nodes);
|
|
31
|
+
}
|
|
32
|
+
}, 5); // Using a lower priority so it runs after transforms from other systems have settled
|
|
22
33
|
return null;
|
|
23
34
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
/**
|
|
5
|
+
* Instantly snaps all level Objects3D to their true stacked Y positions
|
|
6
|
+
* (ignores levelMode — always uses stacked, no exploded gap).
|
|
7
|
+
*
|
|
8
|
+
* Returns a restore function that reverts each level's Y to what it was
|
|
9
|
+
* before the snap, so lerp animations in LevelSystem can continue undisturbed.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const restore = snapLevelsToTruePositions()
|
|
13
|
+
* renderer.render(scene, camera)
|
|
14
|
+
* restore()
|
|
15
|
+
*/
|
|
16
|
+
export declare function snapLevelsToTruePositions(): () => void;
|
|
17
|
+
//# sourceMappingURL=level-utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"level-utils.d.ts","sourceRoot":"","sources":["../../../src/systems/level/level-utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,QAAQ,EAET,MAAM,kBAAkB,CAAA;AAEzB,eAAO,MAAM,oBAAoB,MAAM,CAAA;AAQvC,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,UAAU,CAAC,OAAO,QAAQ,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,GACnD,MAAM,CA8BR;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,IAAI,MAAM,IAAI,CAyCtD"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { sceneRegistry, useScene, } from '@pascal-app/core';
|
|
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
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Instantly snaps all level Objects3D to their true stacked Y positions
|
|
43
|
+
* (ignores levelMode — always uses stacked, no exploded gap).
|
|
44
|
+
*
|
|
45
|
+
* Returns a restore function that reverts each level's Y to what it was
|
|
46
|
+
* before the snap, so lerp animations in LevelSystem can continue undisturbed.
|
|
47
|
+
*
|
|
48
|
+
* Usage:
|
|
49
|
+
* const restore = snapLevelsToTruePositions()
|
|
50
|
+
* renderer.render(scene, camera)
|
|
51
|
+
* restore()
|
|
52
|
+
*/
|
|
53
|
+
export function snapLevelsToTruePositions() {
|
|
54
|
+
const nodes = useScene.getState().nodes;
|
|
55
|
+
const entries = [];
|
|
56
|
+
sceneRegistry.byType.level.forEach((levelId) => {
|
|
57
|
+
const obj = sceneRegistry.nodes.get(levelId);
|
|
58
|
+
const level = nodes[levelId];
|
|
59
|
+
if (obj && level) {
|
|
60
|
+
entries.push({ levelId, index: level.level ?? 0, obj });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
entries.sort((a, b) => a.index - b.index);
|
|
64
|
+
// Snapshot current Y and visibility so we can restore them after the render
|
|
65
|
+
const snapshot = new Map(entries.map(({ levelId, obj }) => [levelId, { y: obj.position.y, visible: obj.visible }]));
|
|
66
|
+
// Snap to true stacked positions and make all levels visible
|
|
67
|
+
let cumulativeY = 0;
|
|
68
|
+
for (const { levelId, obj } of entries) {
|
|
69
|
+
obj.position.y = cumulativeY;
|
|
70
|
+
obj.visible = true;
|
|
71
|
+
cumulativeY += getLevelHeight(levelId, nodes);
|
|
72
|
+
}
|
|
73
|
+
return () => {
|
|
74
|
+
for (const { levelId, obj } of entries) {
|
|
75
|
+
const saved = snapshot.get(levelId);
|
|
76
|
+
if (saved !== undefined) {
|
|
77
|
+
obj.position.y = saved.y;
|
|
78
|
+
obj.visible = saved.visible;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wall-cutout.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-cutout.tsx"],"names":[],"mappings":"AAgDA,eAAO,MAAM,UAAU,
|
|
1
|
+
{"version":3,"file":"wall-cutout.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-cutout.tsx"],"names":[],"mappings":"AAgDA,eAAO,MAAM,UAAU,YAgEtB,CAAA"}
|
|
@@ -85,11 +85,9 @@ export const WallCutout = () => {
|
|
|
85
85
|
hideWall = true;
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
else {
|
|
88
|
+
else if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') {
|
|
89
89
|
// Back side
|
|
90
|
-
|
|
91
|
-
hideWall = true;
|
|
92
|
-
}
|
|
90
|
+
hideWall = true;
|
|
93
91
|
}
|
|
94
92
|
}
|
|
95
93
|
;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"zone-system.d.ts","sourceRoot":"","sources":["../../../src/systems/zone/zone-system.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"zone-system.d.ts","sourceRoot":"","sources":["../../../src/systems/zone/zone-system.tsx"],"names":[],"mappings":"AAUA,eAAO,MAAM,UAAU,YAuFtB,CAAA"}
|
|
@@ -4,21 +4,47 @@ import { useRef } from 'react';
|
|
|
4
4
|
import { MathUtils } from 'three';
|
|
5
5
|
import useViewer from '../../store/use-viewer';
|
|
6
6
|
const TRANSITION_DURATION = 400; // ms
|
|
7
|
+
const EXIT_DEBOUNCE_MS = 50; // ignore rapid exit→re-enter within this window
|
|
7
8
|
export const ZoneSystem = () => {
|
|
8
9
|
const lastHighlightedZoneRef = useRef(null);
|
|
9
10
|
const lastChangeTimeRef = useRef(0);
|
|
10
11
|
const isTransitioningRef = useRef(false);
|
|
12
|
+
// Debounce exit-to-null: track the raw pending value and when it last changed
|
|
13
|
+
const pendingZoneRef = useRef(null);
|
|
14
|
+
const pendingZoneSinceRef = useRef(0);
|
|
11
15
|
useFrame(({ clock }, delta) => {
|
|
12
16
|
const hoveredId = useViewer.getState().hoveredId;
|
|
13
|
-
let
|
|
17
|
+
let rawZone = null;
|
|
14
18
|
if (hoveredId) {
|
|
15
19
|
const hoveredNode = useScene.getState().nodes[hoveredId];
|
|
16
20
|
if (hoveredNode?.type === 'zone') {
|
|
17
|
-
|
|
21
|
+
rawZone = hoveredId;
|
|
18
22
|
}
|
|
19
23
|
}
|
|
20
|
-
//
|
|
24
|
+
// Update pending zone when the raw value changes
|
|
25
|
+
if (rawZone !== pendingZoneRef.current) {
|
|
26
|
+
pendingZoneRef.current = rawZone;
|
|
27
|
+
pendingZoneSinceRef.current = clock.elapsedTime * 1000;
|
|
28
|
+
}
|
|
29
|
+
// Apply non-null immediately; debounce null to filter out brief exits
|
|
30
|
+
const age = clock.elapsedTime * 1000 - pendingZoneSinceRef.current;
|
|
31
|
+
const highlightedZone = rawZone !== null ? rawZone : age >= EXIT_DEBOUNCE_MS ? null : lastHighlightedZoneRef.current;
|
|
32
|
+
// Detect stable zone change
|
|
21
33
|
if (highlightedZone !== lastHighlightedZoneRef.current) {
|
|
34
|
+
// Fade out previous zone label-pin
|
|
35
|
+
if (lastHighlightedZoneRef.current) {
|
|
36
|
+
const prevLabel = document.getElementById(`${lastHighlightedZoneRef.current}-label`);
|
|
37
|
+
const pin = prevLabel?.querySelector('.label-pin');
|
|
38
|
+
if (pin)
|
|
39
|
+
pin.style.opacity = '0';
|
|
40
|
+
}
|
|
41
|
+
// Fade in new zone label-pin
|
|
42
|
+
if (highlightedZone) {
|
|
43
|
+
const label = document.getElementById(`${highlightedZone}-label`);
|
|
44
|
+
const pin = label?.querySelector('.label-pin');
|
|
45
|
+
if (pin)
|
|
46
|
+
pin.style.opacity = '1';
|
|
47
|
+
}
|
|
22
48
|
lastHighlightedZoneRef.current = highlightedZone;
|
|
23
49
|
lastChangeTimeRef.current = clock.elapsedTime * 1000;
|
|
24
50
|
isTransitioningRef.current = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "3D viewer component for Pascal building editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -26,16 +26,18 @@
|
|
|
26
26
|
"@react-three/drei": "^10",
|
|
27
27
|
"@react-three/fiber": "^9",
|
|
28
28
|
"react": "^18 || ^19",
|
|
29
|
-
"three": "^0.
|
|
29
|
+
"three": "^0.183"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"polygon-clipping": "^0.15.7",
|
|
32
33
|
"zustand": "^5"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
|
-
"@
|
|
36
|
+
"@pascal/typescript-config": "*",
|
|
37
|
+
"@types/node": "^25.5.0",
|
|
36
38
|
"@types/react": "^19.2.2",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
+
"@types/three": "^0.183.0",
|
|
40
|
+
"typescript": "5.9.3"
|
|
39
41
|
},
|
|
40
42
|
"keywords": [
|
|
41
43
|
"3d",
|