let-them-talk 5.3.0 → 5.4.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 (166) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/README.md +158 -592
  3. package/SECURITY.md +3 -3
  4. package/USAGE.md +151 -0
  5. package/agent-contracts.js +447 -0
  6. package/api-agents.js +760 -0
  7. package/autonomy/decision-v2.js +380 -0
  8. package/autonomy/watchdog-policy.js +572 -0
  9. package/cli.js +454 -298
  10. package/conversation-templates/autonomous-feature.json +83 -22
  11. package/conversation-templates/code-review.json +69 -21
  12. package/conversation-templates/debug-squad.json +69 -21
  13. package/conversation-templates/feature-build.json +69 -21
  14. package/conversation-templates/research-write.json +69 -21
  15. package/dashboard.html +3148 -174
  16. package/dashboard.js +823 -786
  17. package/data-dir.js +58 -0
  18. package/docs/architecture/branch-semantics.md +157 -0
  19. package/docs/architecture/canonical-event-schema.md +88 -0
  20. package/docs/architecture/markdown-workspace.md +183 -0
  21. package/docs/architecture/runtime-contract.md +459 -0
  22. package/docs/architecture/runtime-migration-hardening.md +64 -0
  23. package/events/hooks.js +154 -0
  24. package/events/log.js +457 -0
  25. package/events/replay.js +33 -0
  26. package/events/schema.js +432 -0
  27. package/managed-team-integration.js +261 -0
  28. package/office/agents.js +704 -597
  29. package/office/animation.js +1 -1
  30. package/office/assets/arcade-cabinet.js +141 -0
  31. package/office/assets/archway.js +77 -0
  32. package/office/assets/bar-counter.js +91 -0
  33. package/office/assets/bar-stool.js +71 -0
  34. package/office/assets/beanbag.js +64 -0
  35. package/office/assets/bench.js +99 -0
  36. package/office/assets/bollard.js +87 -0
  37. package/office/assets/cactus.js +100 -0
  38. package/office/assets/carpet-tile.js +46 -0
  39. package/office/assets/chair.js +123 -0
  40. package/office/assets/chandelier.js +107 -0
  41. package/office/assets/coffee-machine.js +95 -0
  42. package/office/assets/coffee-table.js +81 -0
  43. package/office/assets/column.js +95 -0
  44. package/office/assets/desk-lamp.js +102 -0
  45. package/office/assets/desk.js +76 -0
  46. package/office/assets/dining-table.js +105 -0
  47. package/office/assets/door.js +70 -0
  48. package/office/assets/dual-monitor.js +72 -0
  49. package/office/assets/fence.js +76 -0
  50. package/office/assets/filing-cabinet.js +111 -0
  51. package/office/assets/floor-lamp.js +69 -0
  52. package/office/assets/floor-tile.js +54 -0
  53. package/office/assets/flower-pot.js +76 -0
  54. package/office/assets/foosball.js +95 -0
  55. package/office/assets/fridge.js +99 -0
  56. package/office/assets/gaming-chair.js +154 -0
  57. package/office/assets/gaming-desk.js +105 -0
  58. package/office/assets/glass-door.js +72 -0
  59. package/office/assets/glass-wall.js +64 -0
  60. package/office/assets/half-wall.js +49 -0
  61. package/office/assets/hanging-plant.js +112 -0
  62. package/office/assets/index.js +151 -0
  63. package/office/assets/indoor-tree.js +90 -0
  64. package/office/assets/l-sofa.js +153 -0
  65. package/office/assets/marble-floor.js +64 -0
  66. package/office/assets/materials.js +40 -0
  67. package/office/assets/meeting-table.js +88 -0
  68. package/office/assets/microwave.js +94 -0
  69. package/office/assets/monitor.js +67 -0
  70. package/office/assets/neon-strip.js +73 -0
  71. package/office/assets/painting.js +84 -0
  72. package/office/assets/palm-tree.js +108 -0
  73. package/office/assets/pc-tower.js +91 -0
  74. package/office/assets/pendant-light.js +67 -0
  75. package/office/assets/ping-pong.js +114 -0
  76. package/office/assets/plant.js +72 -0
  77. package/office/assets/planter-box.js +95 -0
  78. package/office/assets/pool-table.js +94 -0
  79. package/office/assets/printer.js +113 -0
  80. package/office/assets/reception-desk.js +133 -0
  81. package/office/assets/rug.js +78 -0
  82. package/office/assets/sculpture.js +85 -0
  83. package/office/assets/server-rack.js +98 -0
  84. package/office/assets/sink.js +109 -0
  85. package/office/assets/sofa.js +106 -0
  86. package/office/assets/speaker.js +83 -0
  87. package/office/assets/spotlight.js +83 -0
  88. package/office/assets/street-lamp.js +97 -0
  89. package/office/assets/trash-can.js +83 -0
  90. package/office/assets/treadmill.js +126 -0
  91. package/office/assets/trophy.js +89 -0
  92. package/office/assets/tv-screen.js +79 -0
  93. package/office/assets/vase.js +84 -0
  94. package/office/assets/wall-clock.js +84 -0
  95. package/office/assets/wall.js +53 -0
  96. package/office/assets/water-cooler.js +146 -0
  97. package/office/assets/whiteboard.js +115 -0
  98. package/office/assets.js +3 -431
  99. package/office/builder.js +791 -355
  100. package/office/campus-env.js +1012 -1119
  101. package/office/environment.js +2 -0
  102. package/office/gallery.js +997 -0
  103. package/office/index.js +165 -61
  104. package/office/navigation.js +173 -152
  105. package/office/player.js +178 -68
  106. package/office/robot-character.js +272 -0
  107. package/office/spectator-camera.js +33 -10
  108. package/office/state.js +2 -0
  109. package/office/world-save.js +35 -4
  110. package/package.json +57 -3
  111. package/providers/comfyui.js +383 -0
  112. package/providers/dalle.js +79 -0
  113. package/providers/gemini.js +181 -0
  114. package/providers/ollama.js +184 -0
  115. package/providers/replicate.js +115 -0
  116. package/providers/zai.js +183 -0
  117. package/runtime-descriptor.js +270 -0
  118. package/scripts/check-agent-contract-advisory.js +132 -0
  119. package/scripts/check-api-agent-parity.js +277 -0
  120. package/scripts/check-autonomy-v2-decision.js +207 -0
  121. package/scripts/check-autonomy-v2-execution.js +588 -0
  122. package/scripts/check-autonomy-v2-watchdog.js +224 -0
  123. package/scripts/check-branch-fork-snapshot.js +337 -0
  124. package/scripts/check-branch-isolation.js +787 -0
  125. package/scripts/check-branch-semantics.js +139 -0
  126. package/scripts/check-dashboard-control-plane.js +1304 -0
  127. package/scripts/check-docs-onboarding.js +490 -0
  128. package/scripts/check-event-schema.js +276 -0
  129. package/scripts/check-evidence-completion.js +239 -0
  130. package/scripts/check-invariants.js +992 -0
  131. package/scripts/check-lifecycle-hooks.js +525 -0
  132. package/scripts/check-managed-team-integration.js +166 -0
  133. package/scripts/check-markdown-workspace-export.js +548 -0
  134. package/scripts/check-markdown-workspace-safety.js +347 -0
  135. package/scripts/check-markdown-workspace.js +136 -0
  136. package/scripts/check-message-replay.js +429 -0
  137. package/scripts/check-migration-hardening.js +300 -0
  138. package/scripts/check-performance-indexing.js +272 -0
  139. package/scripts/check-provider-capabilities.js +316 -0
  140. package/scripts/check-runtime-contract.js +109 -0
  141. package/scripts/check-session-aware-context.js +172 -0
  142. package/scripts/check-session-lifecycle.js +210 -0
  143. package/scripts/export-markdown-workspace.js +84 -0
  144. package/scripts/fixtures/message-replay/clean.jsonl +2 -0
  145. package/scripts/fixtures/message-replay/corrupt-correction-payload.jsonl +1 -0
  146. package/scripts/fixtures/message-replay/corrupt-jsonl.jsonl +1 -0
  147. package/scripts/fixtures/message-replay/corrupt-payload.jsonl +1 -0
  148. package/scripts/fixtures/message-replay/out-of-order.jsonl +2 -0
  149. package/scripts/migrate-legacy-to-canonical.js +201 -0
  150. package/scripts/run-verification-suite.js +242 -0
  151. package/scripts/sync-packaged-docs.js +69 -0
  152. package/server.js +9546 -7216
  153. package/state/agents.js +161 -0
  154. package/state/canonical.js +3068 -0
  155. package/state/dashboard-queries.js +441 -0
  156. package/state/evidence.js +56 -0
  157. package/state/io.js +69 -0
  158. package/state/markdown-workspace.js +951 -0
  159. package/state/messages.js +669 -0
  160. package/state/sessions.js +683 -0
  161. package/state/tasks-workflows.js +92 -0
  162. package/templates/debate.json +2 -2
  163. package/templates/managed.json +4 -4
  164. package/templates/pair.json +2 -2
  165. package/templates/review.json +2 -2
  166. package/templates/team.json +3 -3
package/office/builder.js CHANGED
@@ -1,355 +1,791 @@
1
- // builder.js — Real-time World Builder for the 3D Hub
2
- // Press B to toggle builder mode. Click to place assets. R to rotate. Right-click to delete.
3
- import * as THREE from 'three';
4
- import { S } from './state.js';
5
- import { ASSETS, ASSET_CATEGORIES, getAsset, getAssetsByCategory, createGhost } from './assets.js';
6
- import { addPlacement, removePlacement, loadWorld, getPlacements } from './world-save.js';
7
- import { isPlayerMode } from './player.js';
8
-
9
- var _active = false; // builder mode on/off
10
- var _panel = null; // UI panel element
11
- var _selectedAsset = null; // currently selected asset ID
12
- var _ghostMesh = null; // preview mesh following cursor
13
- var _rotation = 0; // current placement rotation (0, PI/2, PI, 3PI/2)
14
- var _placedMeshes = {}; // id → THREE.Group map for deletion
15
- var _gridHelper = null; // grid overlay
16
- var _raycaster = new THREE.Raycaster();
17
- var _mouse = new THREE.Vector2();
18
- var _floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // Y=0 plane
19
- var GRID_SIZE = 0.5; // snap grid in world units
20
- var _undoStack = []; // for undo (Ctrl+Z)
21
-
22
- // ===================== PUBLIC API =====================
23
-
24
- export function isBuilderActive() { return _active; }
25
-
26
- export function toggleBuilder() {
27
- if (_active) exitBuilder();
28
- else enterBuilder();
29
- }
30
-
31
- export function enterBuilder() {
32
- if (_active) return;
33
- _active = true;
34
- showPanel();
35
- showGrid();
36
- addListeners();
37
- }
38
-
39
- export function exitBuilder() {
40
- if (!_active) return;
41
- _active = false;
42
- hidePanel();
43
- hideGrid();
44
- removeGhost();
45
- removeListeners();
46
- _selectedAsset = null;
47
- }
48
-
49
- // Load saved placements and render them in the scene
50
- export function loadSavedWorld() {
51
- loadWorld().then(function(placements) {
52
- if (!placements || !Array.isArray(placements)) return;
53
- for (var i = 0; i < placements.length; i++) {
54
- renderPlacement(placements[i]);
55
- }
56
- });
57
- }
58
-
59
- // ===================== GRID =====================
60
-
61
- function showGrid() {
62
- if (_gridHelper) return;
63
- _gridHelper = new THREE.GridHelper(50, 100, 0x444466, 0x333355); // 50 units, 0.5 unit cells
64
- _gridHelper.position.y = 0.005;
65
- _gridHelper.material.transparent = true;
66
- _gridHelper.material.opacity = 0.3;
67
- S.scene.add(_gridHelper);
68
- }
69
-
70
- function hideGrid() {
71
- if (_gridHelper) {
72
- S.scene.remove(_gridHelper);
73
- _gridHelper.geometry.dispose();
74
- _gridHelper.material.dispose();
75
- _gridHelper = null;
76
- }
77
- }
78
-
79
- // ===================== GHOST PREVIEW =====================
80
-
81
- function setGhost(assetId) {
82
- removeGhost();
83
- if (!assetId) return;
84
- _ghostMesh = createGhost(assetId);
85
- if (_ghostMesh) {
86
- S.scene.add(_ghostMesh);
87
- }
88
- }
89
-
90
- function removeGhost() {
91
- if (_ghostMesh) {
92
- S.scene.remove(_ghostMesh);
93
- _ghostMesh.traverse(function(c) {
94
- if (c.geometry) c.geometry.dispose();
95
- if (c.material) c.material.dispose();
96
- });
97
- _ghostMesh = null;
98
- }
99
- }
100
-
101
- function updateGhostPosition(event) {
102
- if (!_ghostMesh || !S.renderer || !S.camera) return;
103
- var rect = S.renderer.domElement.getBoundingClientRect();
104
- _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
105
- _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
106
- _raycaster.setFromCamera(_mouse, S.camera);
107
-
108
- var intersect = new THREE.Vector3();
109
- _raycaster.ray.intersectPlane(_floorPlane, intersect);
110
- if (intersect) {
111
- // Snap to grid
112
- intersect.x = Math.round(intersect.x / GRID_SIZE) * GRID_SIZE;
113
- intersect.z = Math.round(intersect.z / GRID_SIZE) * GRID_SIZE;
114
- intersect.y = 0;
115
- _ghostMesh.position.copy(intersect);
116
- _ghostMesh.rotation.y = _rotation;
117
- }
118
- }
119
-
120
- // ===================== PLACEMENT =====================
121
-
122
- function placeAsset(event) {
123
- if (!_selectedAsset || !_ghostMesh) return;
124
-
125
- var pos = _ghostMesh.position.clone();
126
- var entry = addPlacement(_selectedAsset, pos.x, pos.y, pos.z, _rotation, 'user');
127
- renderPlacement(entry);
128
- _undoStack.push(entry.id);
129
- }
130
-
131
- function renderPlacement(entry) {
132
- var asset = getAsset(entry.type);
133
- if (!asset) return;
134
- var group = asset.factory();
135
- group.position.set(entry.x, entry.y || 0, entry.z);
136
- group.rotation.y = entry.rotY || 0;
137
- group.userData.placementId = entry.id;
138
- group.userData.isPlaced = true;
139
- S.scene.add(group);
140
- _placedMeshes[entry.id] = group;
141
- }
142
-
143
- function deleteNearestPlacement(event) {
144
- if (!S.renderer || !S.camera) return;
145
- var rect = S.renderer.domElement.getBoundingClientRect();
146
- _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
147
- _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
148
- _raycaster.setFromCamera(_mouse, S.camera);
149
-
150
- // Collect all placed meshes
151
- var meshes = [];
152
- for (var id in _placedMeshes) {
153
- _placedMeshes[id].traverse(function(c) {
154
- if (c.isMesh) { c.userData._placementId = id; meshes.push(c); }
155
- });
156
- }
157
-
158
- var hits = _raycaster.intersectObjects(meshes, false);
159
- if (hits.length > 0) {
160
- var hitId = hits[0].object.userData._placementId;
161
- if (hitId && _placedMeshes[hitId]) {
162
- // Remove from scene
163
- var group = _placedMeshes[hitId];
164
- S.scene.remove(group);
165
- group.traverse(function(c) {
166
- if (c.geometry) c.geometry.dispose();
167
- if (c.material && !c.material._shared) c.material.dispose();
168
- });
169
- delete _placedMeshes[hitId];
170
- // Remove from save data
171
- removePlacement(hitId);
172
- }
173
- }
174
- }
175
-
176
- function undoLast() {
177
- if (_undoStack.length === 0) return;
178
- var lastId = _undoStack.pop();
179
- if (_placedMeshes[lastId]) {
180
- var group = _placedMeshes[lastId];
181
- S.scene.remove(group);
182
- group.traverse(function(c) {
183
- if (c.geometry) c.geometry.dispose();
184
- if (c.material) c.material.dispose();
185
- });
186
- delete _placedMeshes[lastId];
187
- removePlacement(lastId);
188
- }
189
- }
190
-
191
- // ===================== UI PANEL =====================
192
-
193
- function showPanel() {
194
- if (_panel) return;
195
- _panel = document.createElement('div');
196
- _panel.id = 'builder-panel';
197
- _panel.style.cssText = 'position:fixed;right:12px;top:80px;z-index:999999;width:180px;max-height:70vh;overflow-y:auto;background:rgba(22,27,34,0.95);border:1px solid #30363d;border-radius:10px;padding:0 8px 8px;font-family:system-ui;color:#e6edf3;backdrop-filter:blur(8px);';
198
-
199
- // Drag handle header
200
- var header = document.createElement('div');
201
- header.style.cssText = 'text-align:center;font-size:13px;font-weight:bold;color:#58a6ff;padding:8px 0;border-bottom:1px solid #30363d;margin-bottom:8px;cursor:grab;user-select:none;';
202
- header.textContent = '\u2630 World Builder';
203
- header.title = 'Drag to move';
204
- _panel.appendChild(header);
205
-
206
- // Drag logic store refs for cleanup in hidePanel()
207
- var dragOffX = 0, dragOffY = 0, dragging = false;
208
- header.addEventListener('mousedown', function(e) {
209
- dragging = true;
210
- dragOffX = e.clientX - _panel.getBoundingClientRect().left;
211
- dragOffY = e.clientY - _panel.getBoundingClientRect().top;
212
- header.style.cursor = 'grabbing';
213
- e.preventDefault();
214
- });
215
- _panel._dragMove = function(e) {
216
- if (!dragging || !_panel) return;
217
- _panel.style.left = (e.clientX - dragOffX) + 'px';
218
- _panel.style.top = (e.clientY - dragOffY) + 'px';
219
- _panel.style.right = 'auto';
220
- _panel.style.transform = 'none';
221
- };
222
- _panel._dragUp = function() {
223
- if (dragging) { dragging = false; if (header) header.style.cursor = 'grab'; }
224
- };
225
- document.addEventListener('mousemove', _panel._dragMove);
226
- document.addEventListener('mouseup', _panel._dragUp);
227
-
228
- // Hint
229
- var hint = document.createElement('div');
230
- hint.style.cssText = 'font-size:9px;color:#8b949e;text-align:center;margin-bottom:8px;';
231
- hint.textContent = 'Click=Place | R=Rotate | Right-click=Delete | Ctrl+Z=Undo | B=Close';
232
- _panel.appendChild(hint);
233
-
234
- // Categories + assets
235
- for (var ci = 0; ci < ASSET_CATEGORIES.length; ci++) {
236
- var cat = ASSET_CATEGORIES[ci];
237
- var catAssets = getAssetsByCategory(cat.id);
238
- if (catAssets.length === 0) continue;
239
-
240
- var catLabel = document.createElement('div');
241
- catLabel.style.cssText = 'font-size:10px;color:#8b949e;padding:4px 0 2px;text-transform:uppercase;letter-spacing:1px;';
242
- catLabel.textContent = cat.icon + ' ' + cat.label;
243
- _panel.appendChild(catLabel);
244
-
245
- for (var ai = 0; ai < catAssets.length; ai++) {
246
- var asset = catAssets[ai];
247
- var btn = document.createElement('button');
248
- btn.style.cssText = 'display:block;width:100%;padding:6px 8px;margin:2px 0;background:rgba(48,54,61,0.6);border:1px solid transparent;border-radius:6px;color:#c9d1d9;font-size:11px;cursor:pointer;text-align:left;transition:all 0.15s;';
249
- btn.textContent = asset.icon + ' ' + asset.name;
250
- btn.dataset.assetId = asset.id;
251
- btn.addEventListener('mouseenter', function() { this.style.background = 'rgba(88,166,255,0.2)'; this.style.borderColor = '#58a6ff'; });
252
- btn.addEventListener('mouseleave', function() {
253
- if (this.dataset.assetId !== _selectedAsset) {
254
- this.style.background = 'rgba(48,54,61,0.6)'; this.style.borderColor = 'transparent';
255
- }
256
- });
257
- btn.addEventListener('click', function(e) {
258
- e.stopPropagation();
259
- var id = this.dataset.assetId;
260
- _selectedAsset = id;
261
- _rotation = 0;
262
- setGhost(id);
263
- // Highlight selected
264
- var btns = _panel.querySelectorAll('button');
265
- for (var b = 0; b < btns.length; b++) {
266
- btns[b].style.background = 'rgba(48,54,61,0.6)'; btns[b].style.borderColor = 'transparent';
267
- }
268
- this.style.background = 'rgba(88,166,255,0.3)'; this.style.borderColor = '#58a6ff';
269
- });
270
- _panel.appendChild(btn);
271
- }
272
- }
273
-
274
- // Append to fullscreen element if active, otherwise document.body
275
- // This ensures the builder panel is visible when the 3D container is fullscreened
276
- var target = document.fullscreenElement || document.body;
277
- target.appendChild(_panel);
278
- }
279
-
280
- function hidePanel() {
281
- if (_panel) {
282
- // Remove drag listeners to prevent leaks
283
- if (_panel._dragMove) document.removeEventListener('mousemove', _panel._dragMove);
284
- if (_panel._dragUp) document.removeEventListener('mouseup', _panel._dragUp);
285
- if (_panel.parentElement) _panel.remove();
286
- }
287
- _panel = null;
288
- }
289
-
290
- // ===================== EVENT LISTENERS =====================
291
-
292
- var _onMouseMove = null;
293
- var _onMouseDown = null;
294
- var _onContextMenu = null;
295
- var _onKeyDown = null;
296
-
297
- function addListeners() {
298
- _onMouseMove = function(e) { updateGhostPosition(e); };
299
- _onMouseDown = function(e) {
300
- if (!_active) return;
301
- if (e.button === 0 && _selectedAsset) { // left click
302
- // Don't place if clicking the panel
303
- if (_panel && _panel.contains(e.target)) return;
304
- placeAsset(e);
305
- }
306
- };
307
- _onContextMenu = function(e) {
308
- if (!_active) return;
309
- e.preventDefault();
310
- deleteNearestPlacement(e);
311
- };
312
- _onKeyDown = function(e) {
313
- if (!_active) return;
314
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
315
- if (e.code === 'KeyR') {
316
- // Rotate 90 degrees
317
- _rotation = (_rotation + Math.PI / 2) % (Math.PI * 2);
318
- if (_ghostMesh) _ghostMesh.rotation.y = _rotation;
319
- }
320
- if (e.code === 'KeyZ' && (e.ctrlKey || e.metaKey)) {
321
- e.preventDefault();
322
- undoLast();
323
- }
324
- };
325
-
326
- window.addEventListener('mousemove', _onMouseMove);
327
- window.addEventListener('mousedown', _onMouseDown);
328
- window.addEventListener('contextmenu', _onContextMenu);
329
- window.addEventListener('keydown', _onKeyDown);
330
- }
331
-
332
- function removeListeners() {
333
- if (_onMouseMove) window.removeEventListener('mousemove', _onMouseMove);
334
- if (_onMouseDown) window.removeEventListener('mousedown', _onMouseDown);
335
- if (_onContextMenu) window.removeEventListener('contextmenu', _onContextMenu);
336
- if (_onKeyDown) window.removeEventListener('keydown', _onKeyDown);
337
- _onMouseMove = _onMouseDown = _onContextMenu = _onKeyDown = null;
338
- }
339
-
340
- // ===================== CLEANUP =====================
341
-
342
- export function cleanupBuilder() {
343
- exitBuilder();
344
- // Remove all placed meshes from scene
345
- for (var id in _placedMeshes) {
346
- var group = _placedMeshes[id];
347
- S.scene.remove(group);
348
- group.traverse(function(c) {
349
- if (c.geometry) c.geometry.dispose();
350
- if (c.material) c.material.dispose();
351
- });
352
- }
353
- _placedMeshes = {};
354
- _undoStack = [];
355
- }
1
+ // builder.js — World Builder for the 3D Hub
2
+ // B = toggle builder | Click = place | R = rotate | Right-click = delete
3
+ // Ctrl+Z = undo | Ctrl+Shift+Z = redo | G = grab/move selected | Escape = deselect
4
+ // Snaps objects on top of surfaces (desks, tables, etc.) not just the floor
5
+ import * as THREE from 'three';
6
+ import { S } from './state.js';
7
+ import { ASSETS, ASSET_CATEGORIES, getAsset, getAssetsByCategory, createGhost } from './assets.js';
8
+ import { addPlacement, removePlacement, restorePlacement, loadWorld, getPlacements } from './world-save.js';
9
+ import { isPlayerMode } from './player.js';
10
+
11
+ var _active = false;
12
+ var _panel = null;
13
+ var _selectedAsset = null; // asset ID for placement mode
14
+ var _ghostMesh = null;
15
+ var _rotation = 0;
16
+ var _placedMeshes = {}; // placementId → THREE.Group
17
+ var _gridHelper = null;
18
+ var _raycaster = new THREE.Raycaster();
19
+ var _mouse = new THREE.Vector2();
20
+ var _floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
21
+ var GRID_SIZE = 0.5;
22
+
23
+ // Undo / Redo
24
+ var _undoStack = []; // { action, data }
25
+ var _redoStack = [];
26
+
27
+ // Campus edit
28
+ var _selectedCampusObj = null; // currently selected campus furniture group
29
+ var _selectionBox = null; // wireframe highlight around selected
30
+ var _grabMode = false; // G key — moving selected object
31
+ var _campusBackedUp = false;
32
+
33
+ // Locked types — these campus objects cannot be moved or deleted
34
+ var LOCKED_TYPES = ['floor', 'wall', 'ceiling', 'roof', 'skylight', 'column', 'beam', 'corridor'];
35
+
36
+ // ===================== PUBLIC API =====================
37
+
38
+ export function isBuilderActive() { return _active; }
39
+
40
+ export function toggleBuilder() {
41
+ if (_active) exitBuilder(); else enterBuilder();
42
+ }
43
+
44
+ export function enterBuilder() {
45
+ if (_active) return;
46
+ _active = true;
47
+ window._builderActive = true;
48
+ // Release pointer lock so mouse cursor is available for builder UI
49
+ if (document.pointerLockElement) document.exitPointerLock();
50
+ _tagCampusFurniture();
51
+ showPanel();
52
+ showGrid();
53
+ addListeners();
54
+ }
55
+
56
+ export function exitBuilder() {
57
+ if (!_active) return;
58
+ _active = false;
59
+ window._builderActive = false;
60
+ hidePanel();
61
+ hideGrid();
62
+ removeGhost();
63
+ _deselectCampus();
64
+ removeListeners();
65
+ _selectedAsset = null;
66
+ _grabMode = false;
67
+ }
68
+
69
+ export function loadSavedWorld() {
70
+ loadWorld().then(function(placements) {
71
+ _clearRenderedPlacements();
72
+ if (!placements || !Array.isArray(placements)) return;
73
+ for (var i = 0; i < placements.length; i++) {
74
+ renderPlacement(placements[i]);
75
+ }
76
+ });
77
+ }
78
+
79
+ // ===================== CAMPUS FURNITURE TAGGING =====================
80
+ // Tag all furniture in S.furnitureGroup so they can be selected/moved
81
+ // Skip structural elements (walls, floors, ceiling, columns)
82
+
83
+ function _tagCampusFurniture() {
84
+ if (!S.furnitureGroup) return;
85
+ var id = 0;
86
+ S.furnitureGroup.children.forEach(function(child) {
87
+ if (!child.userData._campusId) {
88
+ child.userData._campusId = 'campus_' + (id++);
89
+ // Determine if this is a locked structural element by checking geometry
90
+ var isLocked = _isStructural(child);
91
+ child.userData._campusLocked = isLocked;
92
+ child.userData._campusOrigPos = child.position.clone();
93
+ child.userData._campusOrigRot = child.rotation.clone();
94
+ }
95
+ });
96
+ }
97
+
98
+ function _isStructural(obj) {
99
+ // Check if object is structural by name or geometry characteristics
100
+ // Floor: very flat, large, at Y~0 | Wall: tall thin | Ceiling: flat at Y~6
101
+ var box = new THREE.Box3().setFromObject(obj);
102
+ var size = box.getSize(new THREE.Vector3());
103
+ var center = box.getCenter(new THREE.Vector3());
104
+
105
+ // Floor plane (very thin, very wide)
106
+ if (size.y < 0.1 && size.x > 20 && size.z > 20 && center.y < 0.5) return true;
107
+ // Ceiling (at height ~6)
108
+ if (center.y > 5 && size.y < 0.5 && size.x > 10) return true;
109
+ // Full-height walls (tall + thin in one axis)
110
+ if (size.y > 4 && (size.x < 0.5 || size.z < 0.5) && (size.x > 10 || size.z > 10)) return true;
111
+ // Roof group
112
+ if (obj === S._roofGroup) return true;
113
+
114
+ return false;
115
+ }
116
+
117
+ // ===================== BACKUP SYSTEM =====================
118
+
119
+ function _backupCampus() {
120
+ if (_campusBackedUp) return;
121
+ _campusBackedUp = true;
122
+ // Save original positions of all campus furniture to localStorage
123
+ var backup = {};
124
+ if (!S.furnitureGroup) return;
125
+ S.furnitureGroup.children.forEach(function(child) {
126
+ if (child.userData._campusId) {
127
+ backup[child.userData._campusId] = {
128
+ x: child.position.x, y: child.position.y, z: child.position.z,
129
+ rx: child.rotation.x, ry: child.rotation.y, rz: child.rotation.z,
130
+ visible: child.visible
131
+ };
132
+ }
133
+ });
134
+ try {
135
+ localStorage.setItem('ltt_campus_backup', JSON.stringify(backup));
136
+ } catch (e) {}
137
+ }
138
+
139
+ export function restoreCampus() {
140
+ try {
141
+ var data = localStorage.getItem('ltt_campus_backup');
142
+ if (!data) return false;
143
+ var backup = JSON.parse(data);
144
+ if (!S.furnitureGroup) return false;
145
+ S.furnitureGroup.children.forEach(function(child) {
146
+ var id = child.userData._campusId;
147
+ if (id && backup[id]) {
148
+ child.position.set(backup[id].x, backup[id].y, backup[id].z);
149
+ child.rotation.set(backup[id].rx, backup[id].ry, backup[id].rz);
150
+ child.visible = backup[id].visible !== false;
151
+ }
152
+ });
153
+ return true;
154
+ } catch (e) { return false; }
155
+ }
156
+
157
+ // ===================== GRID =====================
158
+
159
+ function showGrid() {
160
+ if (_gridHelper) return;
161
+ _gridHelper = new THREE.GridHelper(90, 180, 0x444466, 0x333355);
162
+ _gridHelper.position.y = 0.005;
163
+ _gridHelper.material.transparent = true;
164
+ _gridHelper.material.opacity = 0.25;
165
+ S.scene.add(_gridHelper);
166
+ }
167
+
168
+ function hideGrid() {
169
+ if (_gridHelper) {
170
+ S.scene.remove(_gridHelper);
171
+ _gridHelper.geometry.dispose();
172
+ if (Array.isArray(_gridHelper.material)) {
173
+ _gridHelper.material.forEach(function(m) { m.dispose(); });
174
+ } else {
175
+ _gridHelper.material.dispose();
176
+ }
177
+ _gridHelper = null;
178
+ }
179
+ }
180
+
181
+ // ===================== GHOST PREVIEW =====================
182
+
183
+ function setGhost(assetId) {
184
+ removeGhost();
185
+ _deselectCampus();
186
+ if (!assetId) return;
187
+ _ghostMesh = createGhost(assetId);
188
+ if (_ghostMesh) S.scene.add(_ghostMesh);
189
+ }
190
+
191
+ function removeGhost() {
192
+ if (_ghostMesh) {
193
+ S.scene.remove(_ghostMesh);
194
+ _ghostMesh.traverse(function(c) {
195
+ if (c.geometry) c.geometry.dispose();
196
+ if (c.material) c.material.dispose();
197
+ });
198
+ _ghostMesh = null;
199
+ }
200
+ }
201
+
202
+ // ===================== SURFACE SNAPPING =====================
203
+ // Raycast downward from above to find the surface to place on
204
+
205
+ function updateGhostPosition(event) {
206
+ if (!_ghostMesh || !S.renderer || !S.camera) return;
207
+ var rect = S.renderer.domElement.getBoundingClientRect();
208
+ _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
209
+ _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
210
+ _raycaster.setFromCamera(_mouse, S.camera);
211
+
212
+ // Step 1: find where cursor ray hits the floor plane (for X/Z)
213
+ var floorHit = new THREE.Vector3();
214
+ _raycaster.ray.intersectPlane(_floorPlane, floorHit);
215
+ if (!floorHit) return;
216
+
217
+ // Snap X/Z to grid
218
+ var snapX = Math.round(floorHit.x / GRID_SIZE) * GRID_SIZE;
219
+ var snapZ = Math.round(floorHit.z / GRID_SIZE) * GRID_SIZE;
220
+
221
+ // Step 2: find the highest surface at this XZ by casting down from above
222
+ var surfaceY = _findSurfaceY(snapX, snapZ);
223
+
224
+ _ghostMesh.position.set(snapX, surfaceY, snapZ);
225
+ _ghostMesh.rotation.y = _rotation;
226
+ }
227
+
228
+ function _findSurfaceY(x, z) {
229
+ // Cast a ray straight down from high up at the given XZ
230
+ var downRay = new THREE.Raycaster(
231
+ new THREE.Vector3(x, 10, z),
232
+ new THREE.Vector3(0, -1, 0),
233
+ 0, 12
234
+ );
235
+
236
+ // Collect all meshes in the furniture group + placed objects (skip ghosts)
237
+ var targets = [];
238
+ if (S.furnitureGroup) {
239
+ S.furnitureGroup.traverse(function(c) {
240
+ if (c.isMesh && !c.userData.isGhost) targets.push(c);
241
+ });
242
+ }
243
+ for (var id in _placedMeshes) {
244
+ _placedMeshes[id].traverse(function(c) {
245
+ if (c.isMesh) targets.push(c);
246
+ });
247
+ }
248
+
249
+ var hits = downRay.intersectObjects(targets, false);
250
+
251
+ // Find the highest horizontal surface (normal pointing up)
252
+ for (var i = 0; i < hits.length; i++) {
253
+ var normal = hits[i].face ? hits[i].face.normal : null;
254
+ if (normal) {
255
+ // Transform normal to world space
256
+ var worldNormal = normal.clone().transformDirection(hits[i].object.matrixWorld);
257
+ // Accept surfaces that face upward (Y component > 0.7)
258
+ if (worldNormal.y > 0.7 && hits[i].point.y > 0.02) {
259
+ return hits[i].point.y;
260
+ }
261
+ }
262
+ }
263
+
264
+ return 0; // default to floor
265
+ }
266
+
267
+ // ===================== PLACEMENT =====================
268
+
269
+ function placeAsset(event) {
270
+ if (!_selectedAsset || !_ghostMesh) return;
271
+ var pos = _ghostMesh.position.clone();
272
+ var entry = addPlacement(_selectedAsset, pos.x, pos.y, pos.z, _rotation, 'user');
273
+ renderPlacement(entry);
274
+ _pushUndo({ action: 'place', id: entry.id });
275
+ }
276
+
277
+ function _disposePlacementMaterial(material) {
278
+ if (!material) return;
279
+ if (Array.isArray(material)) {
280
+ for (var i = 0; i < material.length; i++) {
281
+ if (material[i] && !material[i]._shared) material[i].dispose();
282
+ }
283
+ return;
284
+ }
285
+ if (!material._shared) material.dispose();
286
+ }
287
+
288
+ function _removeRenderedPlacement(id) {
289
+ var group = _placedMeshes[id];
290
+ if (!group) return;
291
+ S.scene.remove(group);
292
+ group.traverse(function(c) {
293
+ if (c.geometry) c.geometry.dispose();
294
+ _disposePlacementMaterial(c.material);
295
+ });
296
+ delete _placedMeshes[id];
297
+ }
298
+
299
+ function _clearRenderedPlacements() {
300
+ for (var id in _placedMeshes) {
301
+ _removeRenderedPlacement(id);
302
+ }
303
+ }
304
+
305
+ function renderPlacement(entry) {
306
+ if (!entry || !entry.id) return;
307
+ _removeRenderedPlacement(entry.id);
308
+ var asset = getAsset(entry.type);
309
+ if (!asset) return;
310
+ var group = asset.factory();
311
+ group.position.set(entry.x, entry.y || 0, entry.z);
312
+ group.rotation.y = entry.rotY || 0;
313
+ group.userData.placementId = entry.id;
314
+ group.userData.isPlaced = true;
315
+ S.scene.add(group);
316
+ _placedMeshes[entry.id] = group;
317
+ }
318
+
319
+ // ===================== DELETION =====================
320
+
321
+ function deleteAtCursor(event) {
322
+ if (!S.renderer || !S.camera) return;
323
+ var rect = S.renderer.domElement.getBoundingClientRect();
324
+ _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
325
+ _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
326
+ _raycaster.setFromCamera(_mouse, S.camera);
327
+
328
+ // Check user-placed objects first
329
+ var placedMeshes = [];
330
+ for (var id in _placedMeshes) {
331
+ _placedMeshes[id].traverse(function(c) {
332
+ if (c.isMesh) { c.userData._placementId = id; placedMeshes.push(c); }
333
+ });
334
+ }
335
+
336
+ var hits = _raycaster.intersectObjects(placedMeshes, false);
337
+ if (hits.length > 0) {
338
+ var hitId = hits[0].object.userData._placementId;
339
+ if (hitId && _placedMeshes[hitId]) {
340
+ _deletePlacedObject(hitId);
341
+ return;
342
+ }
343
+ }
344
+
345
+ // Check campus furniture (non-locked only)
346
+ if (S.furnitureGroup) {
347
+ var campusMeshes = [];
348
+ S.furnitureGroup.children.forEach(function(child) {
349
+ if (child.userData._campusId && !child.userData._campusLocked && child.visible) {
350
+ child.traverse(function(c) {
351
+ if (c.isMesh) { c.userData._campusRef = child; campusMeshes.push(c); }
352
+ });
353
+ }
354
+ });
355
+
356
+ var campusHits = _raycaster.intersectObjects(campusMeshes, false);
357
+ if (campusHits.length > 0) {
358
+ var campusObj = campusHits[0].object.userData._campusRef;
359
+ if (campusObj && campusObj.userData._campusId) {
360
+ _deleteCampusObject(campusObj);
361
+ }
362
+ }
363
+ }
364
+ }
365
+
366
+ function _deletePlacedObject(id) {
367
+ if (!_placedMeshes[id]) return;
368
+ var placement = getPlacements().find(function(p) { return p.id === id; });
369
+ _removeRenderedPlacement(id);
370
+ removePlacement(id);
371
+ _pushUndo({ action: 'delete_placed', id: id, placement: placement });
372
+ }
373
+
374
+ function _deleteCampusObject(obj) {
375
+ _backupCampus();
376
+ var campusId = obj.userData._campusId;
377
+ var prevPos = obj.position.clone();
378
+ var prevRot = obj.rotation.clone();
379
+ obj.visible = false;
380
+ _pushUndo({ action: 'delete_campus', campusId: campusId, prevPos: prevPos, prevRot: prevRot });
381
+ _deselectCampus();
382
+ }
383
+
384
+ // ===================== CAMPUS SELECTION & MOVE =====================
385
+
386
+ function _selectCampusAt(event) {
387
+ if (_selectedAsset) return; // in placement mode, don't select
388
+ if (!S.renderer || !S.camera || !S.furnitureGroup) return;
389
+
390
+ var rect = S.renderer.domElement.getBoundingClientRect();
391
+ _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
392
+ _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
393
+ _raycaster.setFromCamera(_mouse, S.camera);
394
+
395
+ // Also check placed objects
396
+ var allMeshes = [];
397
+ for (var id in _placedMeshes) {
398
+ _placedMeshes[id].traverse(function(c) {
399
+ if (c.isMesh) { c.userData._selectRef = _placedMeshes[id]; c.userData._selectType = 'placed'; allMeshes.push(c); }
400
+ });
401
+ }
402
+
403
+ S.furnitureGroup.children.forEach(function(child) {
404
+ if (child.userData._campusId && !child.userData._campusLocked && child.visible) {
405
+ child.traverse(function(c) {
406
+ if (c.isMesh) { c.userData._selectRef = child; c.userData._selectType = 'campus'; allMeshes.push(c); }
407
+ });
408
+ }
409
+ });
410
+
411
+ var hits = _raycaster.intersectObjects(allMeshes, false);
412
+ if (hits.length > 0) {
413
+ var ref = hits[0].object.userData._selectRef;
414
+ if (ref) {
415
+ _deselectCampus();
416
+ _selectedCampusObj = ref;
417
+ _showSelectionBox(ref);
418
+ return true;
419
+ }
420
+ }
421
+
422
+ _deselectCampus();
423
+ return false;
424
+ }
425
+
426
+ function _deselectCampus() {
427
+ _selectedCampusObj = null;
428
+ _grabMode = false;
429
+ if (_selectionBox) {
430
+ S.scene.remove(_selectionBox);
431
+ _selectionBox.geometry.dispose();
432
+ _selectionBox.material.dispose();
433
+ _selectionBox = null;
434
+ }
435
+ }
436
+
437
+ function _showSelectionBox(obj) {
438
+ if (_selectionBox) {
439
+ S.scene.remove(_selectionBox);
440
+ _selectionBox.geometry.dispose();
441
+ _selectionBox.material.dispose();
442
+ }
443
+ var box = new THREE.Box3().setFromObject(obj);
444
+ var size = box.getSize(new THREE.Vector3());
445
+ var center = box.getCenter(new THREE.Vector3());
446
+
447
+ var geo = new THREE.BoxGeometry(size.x + 0.1, size.y + 0.1, size.z + 0.1);
448
+ var edges = new THREE.EdgesGeometry(geo);
449
+ _selectionBox = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x58a6ff, linewidth: 2 }));
450
+ _selectionBox.position.copy(center);
451
+ S.scene.add(_selectionBox);
452
+ }
453
+
454
+ function _moveSelected(dx, dz) {
455
+ if (!_selectedCampusObj) return;
456
+ _backupCampus();
457
+ var prevPos = _selectedCampusObj.position.clone();
458
+ _selectedCampusObj.position.x += dx;
459
+ _selectedCampusObj.position.z += dz;
460
+ _pushUndo({
461
+ action: 'move_campus',
462
+ campusId: _selectedCampusObj.userData._campusId,
463
+ prevPos: prevPos,
464
+ newPos: _selectedCampusObj.position.clone()
465
+ });
466
+ _showSelectionBox(_selectedCampusObj);
467
+ }
468
+
469
+ function _rotateSelected() {
470
+ if (!_selectedCampusObj) return;
471
+ _backupCampus();
472
+ var prevRot = _selectedCampusObj.rotation.y;
473
+ _selectedCampusObj.rotation.y += Math.PI / 2;
474
+ _pushUndo({
475
+ action: 'rotate_campus',
476
+ campusId: _selectedCampusObj.userData._campusId,
477
+ prevRot: prevRot,
478
+ newRot: _selectedCampusObj.rotation.y
479
+ });
480
+ _showSelectionBox(_selectedCampusObj);
481
+ }
482
+
483
+ // ===================== UNDO / REDO =====================
484
+
485
+ function _pushUndo(entry) {
486
+ _undoStack.push(entry);
487
+ _redoStack = []; // clear redo on new action
488
+ if (_undoStack.length > 100) _undoStack.shift();
489
+ }
490
+
491
+ function _findCampusObj(campusId) {
492
+ if (!S.furnitureGroup) return null;
493
+ for (var i = 0; i < S.furnitureGroup.children.length; i++) {
494
+ if (S.furnitureGroup.children[i].userData._campusId === campusId) return S.furnitureGroup.children[i];
495
+ }
496
+ return null;
497
+ }
498
+
499
+ function doUndo() {
500
+ if (_undoStack.length === 0) return;
501
+ var entry = _undoStack.pop();
502
+
503
+ if (entry.action === 'place') {
504
+ // Undo placement — remove object
505
+ if (_placedMeshes[entry.id]) {
506
+ _removeRenderedPlacement(entry.id);
507
+ var p = getPlacements().find(function(pp) { return pp.id === entry.id; });
508
+ removePlacement(entry.id);
509
+ entry._undoneData = p; // save for redo
510
+ }
511
+ } else if (entry.action === 'delete_placed') {
512
+ // Undo deletion — re-add
513
+ if (entry.placement) {
514
+ var restoredPlacement = restorePlacement(entry.placement);
515
+ if (restoredPlacement) renderPlacement(restoredPlacement);
516
+ }
517
+ } else if (entry.action === 'delete_campus') {
518
+ var obj = _findCampusObj(entry.campusId);
519
+ if (obj) { obj.visible = true; obj.position.copy(entry.prevPos); obj.rotation.copy(entry.prevRot); }
520
+ } else if (entry.action === 'move_campus') {
521
+ var obj2 = _findCampusObj(entry.campusId);
522
+ if (obj2) { obj2.position.copy(entry.prevPos); _showSelectionBox(obj2); }
523
+ } else if (entry.action === 'rotate_campus') {
524
+ var obj3 = _findCampusObj(entry.campusId);
525
+ if (obj3) { obj3.rotation.y = entry.prevRot; _showSelectionBox(obj3); }
526
+ }
527
+
528
+ _redoStack.push(entry);
529
+ }
530
+
531
+ function doRedo() {
532
+ if (_redoStack.length === 0) return;
533
+ var entry = _redoStack.pop();
534
+
535
+ if (entry.action === 'place') {
536
+ if (entry._undoneData) {
537
+ var redonePlacement = restorePlacement(entry._undoneData);
538
+ if (redonePlacement) renderPlacement(redonePlacement);
539
+ }
540
+ } else if (entry.action === 'delete_placed') {
541
+ if (entry.id && _placedMeshes[entry.id]) {
542
+ _deletePlacedObject(entry.id);
543
+ _undoStack.pop(); // _deletePlacedObject pushes undo — remove it
544
+ }
545
+ } else if (entry.action === 'delete_campus') {
546
+ var obj = _findCampusObj(entry.campusId);
547
+ if (obj) obj.visible = false;
548
+ } else if (entry.action === 'move_campus') {
549
+ var obj2 = _findCampusObj(entry.campusId);
550
+ if (obj2) { obj2.position.copy(entry.newPos); _showSelectionBox(obj2); }
551
+ } else if (entry.action === 'rotate_campus') {
552
+ var obj3 = _findCampusObj(entry.campusId);
553
+ if (obj3) { obj3.rotation.y = entry.newRot; _showSelectionBox(obj3); }
554
+ }
555
+
556
+ _undoStack.push(entry);
557
+ }
558
+
559
+ // ===================== UI PANEL =====================
560
+
561
+ function showPanel() {
562
+ if (_panel) return;
563
+ _panel = document.createElement('div');
564
+ _panel.id = 'builder-panel';
565
+ _panel.style.cssText = 'position:fixed;right:12px;top:80px;z-index:999999;width:190px;max-height:70vh;overflow-y:auto;background:rgba(22,27,34,0.95);border:1px solid #30363d;border-radius:10px;padding:0 8px 8px;font-family:system-ui;color:#e6edf3;backdrop-filter:blur(8px);';
566
+
567
+ // Drag handle header
568
+ var header = document.createElement('div');
569
+ header.style.cssText = 'text-align:center;font-size:13px;font-weight:bold;color:#58a6ff;padding:8px 0;border-bottom:1px solid #30363d;margin-bottom:6px;cursor:grab;user-select:none;';
570
+ header.textContent = 'World Builder';
571
+ _panel.appendChild(header);
572
+
573
+ // Drag logic
574
+ var dragOffX = 0, dragOffY = 0, dragging = false;
575
+ header.addEventListener('mousedown', function(e) {
576
+ dragging = true;
577
+ dragOffX = e.clientX - _panel.getBoundingClientRect().left;
578
+ dragOffY = e.clientY - _panel.getBoundingClientRect().top;
579
+ header.style.cursor = 'grabbing';
580
+ e.preventDefault();
581
+ });
582
+ _panel._dragMove = function(e) {
583
+ if (!dragging || !_panel) return;
584
+ _panel.style.left = (e.clientX - dragOffX) + 'px';
585
+ _panel.style.top = (e.clientY - dragOffY) + 'px';
586
+ _panel.style.right = 'auto';
587
+ };
588
+ _panel._dragUp = function() { if (dragging) { dragging = false; if (header) header.style.cursor = 'grab'; } };
589
+ document.addEventListener('mousemove', _panel._dragMove);
590
+ document.addEventListener('mouseup', _panel._dragUp);
591
+
592
+ // Controls hint
593
+ var hint = document.createElement('div');
594
+ hint.style.cssText = 'font-size:8px;color:#8b949e;text-align:center;margin-bottom:6px;line-height:1.4;';
595
+ hint.innerHTML = 'Click=Place/Select | R=Rotate | RightClick=Delete<br>G=Grab | Arrows=Move | Ctrl+Z/Y=Undo/Redo<br>Esc=Deselect | B=Close';
596
+ _panel.appendChild(hint);
597
+
598
+ // Restore campus button
599
+ var restoreBtn = document.createElement('button');
600
+ restoreBtn.style.cssText = 'display:block;width:100%;padding:5px;margin-bottom:6px;background:rgba(239,68,68,0.15);border:1px solid #ef4444;border-radius:6px;color:#ef4444;font-size:10px;cursor:pointer;';
601
+ restoreBtn.textContent = 'Restore Original Campus';
602
+ restoreBtn.addEventListener('click', function(e) {
603
+ e.stopPropagation();
604
+ if (confirm('Restore campus to original layout? User-placed objects will remain.')) {
605
+ restoreCampus();
606
+ }
607
+ });
608
+ _panel.appendChild(restoreBtn);
609
+
610
+ // Deselect button (asset)
611
+ var deselectBtn = document.createElement('button');
612
+ deselectBtn.style.cssText = 'display:block;width:100%;padding:5px;margin-bottom:8px;background:rgba(48,54,61,0.6);border:1px solid #30363d;border-radius:6px;color:#8b949e;font-size:10px;cursor:pointer;';
613
+ deselectBtn.textContent = 'Deselect Tool (click to select objects)';
614
+ deselectBtn.addEventListener('click', function(e) {
615
+ e.stopPropagation();
616
+ _selectedAsset = null;
617
+ removeGhost();
618
+ // Un-highlight all buttons
619
+ var btns = _panel.querySelectorAll('button[data-asset-id]');
620
+ for (var b = 0; b < btns.length; b++) {
621
+ btns[b].style.background = 'rgba(48,54,61,0.6)'; btns[b].style.borderColor = 'transparent';
622
+ }
623
+ });
624
+ _panel.appendChild(deselectBtn);
625
+
626
+ // Categories + assets
627
+ for (var ci = 0; ci < ASSET_CATEGORIES.length; ci++) {
628
+ var cat = ASSET_CATEGORIES[ci];
629
+ var catAssets = getAssetsByCategory(cat.id);
630
+ if (catAssets.length === 0) continue;
631
+
632
+ var catLabel = document.createElement('div');
633
+ catLabel.style.cssText = 'font-size:10px;color:#8b949e;padding:4px 0 2px;text-transform:uppercase;letter-spacing:1px;cursor:pointer;';
634
+ catLabel.textContent = cat.icon + ' ' + cat.label + ' (' + catAssets.length + ')';
635
+ catLabel.dataset.catId = cat.id;
636
+
637
+ // Collapsible
638
+ var catBody = document.createElement('div');
639
+ catBody.dataset.catBody = cat.id;
640
+ catBody.style.display = ci > 2 ? 'none' : 'block'; // collapse all after first 3
641
+
642
+ catLabel.addEventListener('click', function() {
643
+ var body = _panel.querySelector('[data-cat-body="' + this.dataset.catId + '"]');
644
+ if (body) body.style.display = body.style.display === 'none' ? 'block' : 'none';
645
+ });
646
+
647
+ _panel.appendChild(catLabel);
648
+
649
+ for (var ai = 0; ai < catAssets.length; ai++) {
650
+ var asset = catAssets[ai];
651
+ var btn = document.createElement('button');
652
+ btn.style.cssText = 'display:block;width:100%;padding:5px 8px;margin:1px 0;background:rgba(48,54,61,0.6);border:1px solid transparent;border-radius:5px;color:#c9d1d9;font-size:11px;cursor:pointer;text-align:left;transition:all 0.15s;';
653
+ btn.textContent = asset.icon + ' ' + asset.name;
654
+ btn.dataset.assetId = asset.id;
655
+ btn.addEventListener('mouseenter', function() { this.style.background = 'rgba(88,166,255,0.2)'; this.style.borderColor = '#58a6ff'; });
656
+ btn.addEventListener('mouseleave', function() {
657
+ if (this.dataset.assetId !== _selectedAsset) {
658
+ this.style.background = 'rgba(48,54,61,0.6)'; this.style.borderColor = 'transparent';
659
+ }
660
+ });
661
+ btn.addEventListener('click', function(e) {
662
+ e.stopPropagation();
663
+ var id = this.dataset.assetId;
664
+ _selectedAsset = id;
665
+ _rotation = 0;
666
+ setGhost(id);
667
+ var btns = _panel.querySelectorAll('button[data-asset-id]');
668
+ for (var b = 0; b < btns.length; b++) {
669
+ btns[b].style.background = 'rgba(48,54,61,0.6)'; btns[b].style.borderColor = 'transparent';
670
+ }
671
+ this.style.background = 'rgba(88,166,255,0.3)'; this.style.borderColor = '#58a6ff';
672
+ });
673
+ catBody.appendChild(btn);
674
+ }
675
+ _panel.appendChild(catBody);
676
+ }
677
+
678
+ var target = document.fullscreenElement || document.body;
679
+ target.appendChild(_panel);
680
+ }
681
+
682
+ function hidePanel() {
683
+ if (_panel) {
684
+ if (_panel._dragMove) document.removeEventListener('mousemove', _panel._dragMove);
685
+ if (_panel._dragUp) document.removeEventListener('mouseup', _panel._dragUp);
686
+ if (_panel.parentElement) _panel.remove();
687
+ }
688
+ _panel = null;
689
+ }
690
+
691
+ // ===================== EVENT LISTENERS =====================
692
+
693
+ var _onMouseMove = null, _onMouseDown = null, _onContextMenu = null, _onKeyDown = null;
694
+
695
+ function addListeners() {
696
+ _onMouseMove = function(e) { updateGhostPosition(e); };
697
+
698
+ _onMouseDown = function(e) {
699
+ if (!_active) return;
700
+ if (_panel && _panel.contains(e.target)) return;
701
+
702
+ if (e.button === 0) {
703
+ if (_selectedAsset && _ghostMesh) {
704
+ placeAsset(e);
705
+ } else {
706
+ _selectCampusAt(e);
707
+ }
708
+ }
709
+ };
710
+
711
+ _onContextMenu = function(e) {
712
+ if (!_active) return;
713
+ e.preventDefault();
714
+ deleteAtCursor(e);
715
+ };
716
+
717
+ _onKeyDown = function(e) {
718
+ if (!_active) return;
719
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
720
+
721
+ if (e.code === 'KeyR') {
722
+ if (_selectedCampusObj && !_selectedAsset) {
723
+ _rotateSelected();
724
+ } else {
725
+ _rotation = (_rotation + Math.PI / 2) % (Math.PI * 2);
726
+ if (_ghostMesh) _ghostMesh.rotation.y = _rotation;
727
+ }
728
+ }
729
+
730
+ if (e.code === 'KeyG' && _selectedCampusObj) {
731
+ _grabMode = !_grabMode;
732
+ }
733
+
734
+ if (e.code === 'Escape') {
735
+ _deselectCampus();
736
+ _selectedAsset = null;
737
+ removeGhost();
738
+ }
739
+
740
+ if (e.code === 'Delete' && _selectedCampusObj) {
741
+ if (_selectedCampusObj.userData._campusId) {
742
+ _deleteCampusObject(_selectedCampusObj);
743
+ } else if (_selectedCampusObj.userData.placementId) {
744
+ _deletePlacedObject(_selectedCampusObj.userData.placementId);
745
+ }
746
+ }
747
+
748
+ // Arrow keys: move selected object by grid
749
+ if (_selectedCampusObj) {
750
+ var step = GRID_SIZE;
751
+ if (e.code === 'ArrowLeft') { _moveSelected(-step, 0); e.preventDefault(); }
752
+ if (e.code === 'ArrowRight') { _moveSelected(step, 0); e.preventDefault(); }
753
+ if (e.code === 'ArrowUp') { _moveSelected(0, -step); e.preventDefault(); }
754
+ if (e.code === 'ArrowDown') { _moveSelected(0, step); e.preventDefault(); }
755
+ }
756
+
757
+ // Undo: Ctrl+Z
758
+ if (e.code === 'KeyZ' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
759
+ e.preventDefault();
760
+ doUndo();
761
+ }
762
+ // Redo: Ctrl+Shift+Z or Ctrl+Y
763
+ if ((e.code === 'KeyZ' && (e.ctrlKey || e.metaKey) && e.shiftKey) ||
764
+ (e.code === 'KeyY' && (e.ctrlKey || e.metaKey))) {
765
+ e.preventDefault();
766
+ doRedo();
767
+ }
768
+ };
769
+
770
+ window.addEventListener('mousemove', _onMouseMove);
771
+ window.addEventListener('mousedown', _onMouseDown);
772
+ window.addEventListener('contextmenu', _onContextMenu);
773
+ window.addEventListener('keydown', _onKeyDown);
774
+ }
775
+
776
+ function removeListeners() {
777
+ if (_onMouseMove) window.removeEventListener('mousemove', _onMouseMove);
778
+ if (_onMouseDown) window.removeEventListener('mousedown', _onMouseDown);
779
+ if (_onContextMenu) window.removeEventListener('contextmenu', _onContextMenu);
780
+ if (_onKeyDown) window.removeEventListener('keydown', _onKeyDown);
781
+ _onMouseMove = _onMouseDown = _onContextMenu = _onKeyDown = null;
782
+ }
783
+
784
+ // ===================== CLEANUP =====================
785
+
786
+ export function cleanupBuilder() {
787
+ exitBuilder();
788
+ _clearRenderedPlacements();
789
+ _undoStack = [];
790
+ _redoStack = [];
791
+ }