@pascal-app/viewer 0.1.12 → 0.2.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.
Files changed (90) hide show
  1. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts.map +1 -1
  2. package/dist/components/renderers/ceiling/ceiling-renderer.js +16 -9
  3. package/dist/components/renderers/door/door-renderer.d.ts +5 -0
  4. package/dist/components/renderers/door/door-renderer.d.ts.map +1 -0
  5. package/dist/components/renderers/door/door-renderer.js +11 -0
  6. package/dist/components/renderers/guide/guide-renderer.d.ts.map +1 -1
  7. package/dist/components/renderers/guide/guide-renderer.js +4 -2
  8. package/dist/components/renderers/item/item-renderer.d.ts.map +1 -1
  9. package/dist/components/renderers/item/item-renderer.js +93 -7
  10. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  11. package/dist/components/renderers/node-renderer.js +4 -1
  12. package/dist/components/renderers/roof/roof-materials.d.ts +4 -0
  13. package/dist/components/renderers/roof/roof-materials.d.ts.map +1 -0
  14. package/dist/components/renderers/roof/roof-materials.js +16 -0
  15. package/dist/components/renderers/roof/roof-renderer.d.ts.map +1 -1
  16. package/dist/components/renderers/roof/roof-renderer.js +5 -1
  17. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts +5 -0
  18. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts.map +1 -0
  19. package/dist/components/renderers/roof-segment/roof-segment-renderer.js +13 -0
  20. package/dist/components/renderers/scan/scan-renderer.d.ts.map +1 -1
  21. package/dist/components/renderers/scan/scan-renderer.js +3 -1
  22. package/dist/components/renderers/scene-renderer.d.ts.map +1 -1
  23. package/dist/components/renderers/scene-renderer.js +3 -3
  24. package/dist/components/renderers/site/site-renderer.d.ts.map +1 -1
  25. package/dist/components/renderers/site/site-renderer.js +5 -20
  26. package/dist/components/renderers/slab/slab-renderer.js +1 -1
  27. package/dist/components/renderers/wall/wall-renderer.d.ts.map +1 -1
  28. package/dist/components/renderers/wall/wall-renderer.js +1 -1
  29. package/dist/components/renderers/window/window-renderer.d.ts +5 -0
  30. package/dist/components/renderers/window/window-renderer.d.ts.map +1 -0
  31. package/dist/components/renderers/window/window-renderer.js +11 -0
  32. package/dist/components/renderers/zone/zone-renderer.d.ts.map +1 -1
  33. package/dist/components/renderers/zone/zone-renderer.js +33 -13
  34. package/dist/components/viewer/ground-occluder.d.ts +2 -0
  35. package/dist/components/viewer/ground-occluder.d.ts.map +1 -0
  36. package/dist/components/viewer/ground-occluder.js +55 -0
  37. package/dist/components/viewer/index.d.ts +1 -0
  38. package/dist/components/viewer/index.d.ts.map +1 -1
  39. package/dist/components/viewer/index.js +59 -6
  40. package/dist/components/viewer/lights.d.ts.map +1 -1
  41. package/dist/components/viewer/lights.js +69 -5
  42. package/dist/components/viewer/perf-monitor.d.ts +2 -0
  43. package/dist/components/viewer/perf-monitor.d.ts.map +1 -0
  44. package/dist/components/viewer/perf-monitor.js +42 -0
  45. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  46. package/dist/components/viewer/post-processing.js +229 -106
  47. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  48. package/dist/components/viewer/selection-manager.js +62 -18
  49. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  50. package/dist/components/viewer/viewer-camera.js +2 -2
  51. package/dist/hooks/use-gltf-ktx2.d.ts +1 -1
  52. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  53. package/dist/hooks/use-gltf-ktx2.js +25 -7
  54. package/dist/hooks/use-node-events.d.ts +13 -1
  55. package/dist/hooks/use-node-events.d.ts.map +1 -1
  56. package/dist/hooks/use-node-events.js +34 -7
  57. package/dist/index.d.ts +4 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +4 -1
  60. package/dist/lib/asset-url.d.ts +1 -1
  61. package/dist/lib/asset-url.d.ts.map +1 -1
  62. package/dist/lib/asset-url.js +2 -1
  63. package/dist/lib/layers.d.ts +5 -0
  64. package/dist/lib/layers.d.ts.map +1 -0
  65. package/dist/lib/layers.js +4 -0
  66. package/dist/store/use-item-light-pool.d.ts +18 -0
  67. package/dist/store/use-item-light-pool.d.ts.map +1 -0
  68. package/dist/store/use-item-light-pool.js +30 -0
  69. package/dist/store/use-viewer.d.ts +54 -7
  70. package/dist/store/use-viewer.d.ts.map +1 -1
  71. package/dist/store/use-viewer.js +81 -17
  72. package/dist/systems/interactive/interactive-system.d.ts +2 -0
  73. package/dist/systems/interactive/interactive-system.d.ts.map +1 -0
  74. package/dist/systems/interactive/interactive-system.js +95 -0
  75. package/dist/systems/item-light/item-light-system.d.ts +2 -0
  76. package/dist/systems/item-light/item-light-system.d.ts.map +1 -0
  77. package/dist/systems/item-light/item-light-system.js +235 -0
  78. package/dist/systems/level/level-system.d.ts.map +1 -1
  79. package/dist/systems/level/level-system.js +19 -8
  80. package/dist/systems/level/level-utils.d.ts +17 -0
  81. package/dist/systems/level/level-utils.d.ts.map +1 -0
  82. package/dist/systems/level/level-utils.js +82 -0
  83. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  84. package/dist/systems/wall/wall-cutout.js +2 -4
  85. package/dist/systems/zone/zone-system.d.ts.map +1 -1
  86. package/dist/systems/zone/zone-system.js +29 -3
  87. package/package.json +10 -7
  88. package/dist/hooks/use-grid-events.d.ts +0 -12
  89. package/dist/hooks/use-grid-events.d.ts.map +0 -1
  90. package/dist/hooks/use-grid-events.js +0 -33
@@ -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,YAkBvB,CAAA"}
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
- const LEVEL_HEIGHT = 2.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
- if (obj) {
14
- const level = useScene.getState().nodes[levelId];
15
- const targetY = (level.level || 0) *
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,YAkEtB,CAAA"}
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
- if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') {
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":"AASA,eAAO,MAAM,UAAU,YA4DtB,CAAA"}
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 highlightedZone = null;
17
+ let rawZone = null;
14
18
  if (hoveredId) {
15
19
  const hoveredNode = useScene.getState().nodes[hoveredId];
16
20
  if (hoveredNode?.type === 'zone') {
17
- highlightedZone = hoveredId;
21
+ rawZone = hoveredId;
18
22
  }
19
23
  }
20
- // Detect zone change
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.1.12",
3
+ "version": "0.2.0",
4
4
  "description": "3D viewer component for Pascal building editor",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,16 +26,17 @@
26
26
  "@react-three/drei": "^10",
27
27
  "@react-three/fiber": "^9",
28
28
  "react": "^18 || ^19",
29
- "three": "^0.182"
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
- "@repo/typescript-config": "*",
36
+ "@pascal/typescript-config": "*",
36
37
  "@types/react": "^19.2.2",
37
- "typescript": "5.9.2",
38
- "@types/three": "^0.182.0"
38
+ "typescript": "5.9.3",
39
+ "@types/three": "^0.183.0"
39
40
  },
40
41
  "keywords": [
41
42
  "3d",
@@ -49,8 +50,10 @@
49
50
  ],
50
51
  "repository": {
51
52
  "type": "git",
52
- "url": "https://github.com/your-username/pascal-editor.git",
53
+ "url": "https://github.com/pascalorg/editor.git",
53
54
  "directory": "packages/viewer"
54
55
  },
55
- "license": "MIT"
56
+ "license": "MIT",
57
+ "homepage": "https://github.com/pascalorg/editor/tree/main/packages/viewer#readme",
58
+ "bugs": "https://github.com/pascalorg/editor/issues"
56
59
  }
@@ -1,12 +0,0 @@
1
- import type { ThreeEvent } from "@react-three/fiber";
2
- export declare function useGridEvents(): {
3
- onPointerDown: (e: ThreeEvent<PointerEvent>) => void;
4
- onPointerUp: (e: ThreeEvent<PointerEvent>) => void;
5
- onClick: (e: ThreeEvent<PointerEvent>) => void;
6
- onPointerEnter: (e: ThreeEvent<PointerEvent>) => void;
7
- onPointerLeave: (e: ThreeEvent<PointerEvent>) => void;
8
- onPointerMove: (e: ThreeEvent<PointerEvent>) => void;
9
- onDoubleClick: (e: ThreeEvent<PointerEvent>) => void;
10
- onContextMenu: (e: ThreeEvent<PointerEvent>) => void;
11
- };
12
- //# sourceMappingURL=use-grid-events.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"use-grid-events.d.ts","sourceRoot":"","sources":["../../src/hooks/use-grid-events.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAErD,wBAAgB,aAAa;uBAYN,UAAU,CAAC,YAAY,CAAC;qBAI1B,UAAU,CAAC,YAAY,CAAC;iBAI5B,UAAU,CAAC,YAAY,CAAC;wBAIjB,UAAU,CAAC,YAAY,CAAC;wBACxB,UAAU,CAAC,YAAY,CAAC;uBACzB,UAAU,CAAC,YAAY,CAAC;uBACxB,UAAU,CAAC,YAAY,CAAC;uBACxB,UAAU,CAAC,YAAY,CAAC;EAE9C"}
@@ -1,33 +0,0 @@
1
- import { emitter } from "@pascal-app/core";
2
- export function useGridEvents() {
3
- const emit = (suffix, e) => {
4
- const eventKey = `grid:${suffix}`;
5
- const payload = {
6
- position: [e.point.x, e.point.y, e.point.z],
7
- nativeEvent: e,
8
- };
9
- emitter.emit(eventKey, payload);
10
- };
11
- return {
12
- onPointerDown: (e) => {
13
- if (e.button !== 0)
14
- return;
15
- emit("pointerdown", e);
16
- },
17
- onPointerUp: (e) => {
18
- if (e.button !== 0)
19
- return;
20
- emit("pointerup", e);
21
- },
22
- onClick: (e) => {
23
- if (e.button !== 0)
24
- return;
25
- emit("click", e);
26
- },
27
- onPointerEnter: (e) => emit("enter", e),
28
- onPointerLeave: (e) => emit("leave", e),
29
- onPointerMove: (e) => emit("move", e),
30
- onDoubleClick: (e) => emit("double-click", e),
31
- onContextMenu: (e) => emit("context-menu", e),
32
- };
33
- }