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.
- package/CHANGELOG.md +3 -1
- package/README.md +158 -592
- package/SECURITY.md +3 -3
- package/USAGE.md +151 -0
- package/agent-contracts.js +447 -0
- package/api-agents.js +760 -0
- package/autonomy/decision-v2.js +380 -0
- package/autonomy/watchdog-policy.js +572 -0
- package/cli.js +454 -298
- package/conversation-templates/autonomous-feature.json +83 -22
- package/conversation-templates/code-review.json +69 -21
- package/conversation-templates/debug-squad.json +69 -21
- package/conversation-templates/feature-build.json +69 -21
- package/conversation-templates/research-write.json +69 -21
- package/dashboard.html +3148 -174
- package/dashboard.js +823 -786
- package/data-dir.js +58 -0
- package/docs/architecture/branch-semantics.md +157 -0
- package/docs/architecture/canonical-event-schema.md +88 -0
- package/docs/architecture/markdown-workspace.md +183 -0
- package/docs/architecture/runtime-contract.md +459 -0
- package/docs/architecture/runtime-migration-hardening.md +64 -0
- package/events/hooks.js +154 -0
- package/events/log.js +457 -0
- package/events/replay.js +33 -0
- package/events/schema.js +432 -0
- package/managed-team-integration.js +261 -0
- package/office/agents.js +704 -597
- package/office/animation.js +1 -1
- package/office/assets/arcade-cabinet.js +141 -0
- package/office/assets/archway.js +77 -0
- package/office/assets/bar-counter.js +91 -0
- package/office/assets/bar-stool.js +71 -0
- package/office/assets/beanbag.js +64 -0
- package/office/assets/bench.js +99 -0
- package/office/assets/bollard.js +87 -0
- package/office/assets/cactus.js +100 -0
- package/office/assets/carpet-tile.js +46 -0
- package/office/assets/chair.js +123 -0
- package/office/assets/chandelier.js +107 -0
- package/office/assets/coffee-machine.js +95 -0
- package/office/assets/coffee-table.js +81 -0
- package/office/assets/column.js +95 -0
- package/office/assets/desk-lamp.js +102 -0
- package/office/assets/desk.js +76 -0
- package/office/assets/dining-table.js +105 -0
- package/office/assets/door.js +70 -0
- package/office/assets/dual-monitor.js +72 -0
- package/office/assets/fence.js +76 -0
- package/office/assets/filing-cabinet.js +111 -0
- package/office/assets/floor-lamp.js +69 -0
- package/office/assets/floor-tile.js +54 -0
- package/office/assets/flower-pot.js +76 -0
- package/office/assets/foosball.js +95 -0
- package/office/assets/fridge.js +99 -0
- package/office/assets/gaming-chair.js +154 -0
- package/office/assets/gaming-desk.js +105 -0
- package/office/assets/glass-door.js +72 -0
- package/office/assets/glass-wall.js +64 -0
- package/office/assets/half-wall.js +49 -0
- package/office/assets/hanging-plant.js +112 -0
- package/office/assets/index.js +151 -0
- package/office/assets/indoor-tree.js +90 -0
- package/office/assets/l-sofa.js +153 -0
- package/office/assets/marble-floor.js +64 -0
- package/office/assets/materials.js +40 -0
- package/office/assets/meeting-table.js +88 -0
- package/office/assets/microwave.js +94 -0
- package/office/assets/monitor.js +67 -0
- package/office/assets/neon-strip.js +73 -0
- package/office/assets/painting.js +84 -0
- package/office/assets/palm-tree.js +108 -0
- package/office/assets/pc-tower.js +91 -0
- package/office/assets/pendant-light.js +67 -0
- package/office/assets/ping-pong.js +114 -0
- package/office/assets/plant.js +72 -0
- package/office/assets/planter-box.js +95 -0
- package/office/assets/pool-table.js +94 -0
- package/office/assets/printer.js +113 -0
- package/office/assets/reception-desk.js +133 -0
- package/office/assets/rug.js +78 -0
- package/office/assets/sculpture.js +85 -0
- package/office/assets/server-rack.js +98 -0
- package/office/assets/sink.js +109 -0
- package/office/assets/sofa.js +106 -0
- package/office/assets/speaker.js +83 -0
- package/office/assets/spotlight.js +83 -0
- package/office/assets/street-lamp.js +97 -0
- package/office/assets/trash-can.js +83 -0
- package/office/assets/treadmill.js +126 -0
- package/office/assets/trophy.js +89 -0
- package/office/assets/tv-screen.js +79 -0
- package/office/assets/vase.js +84 -0
- package/office/assets/wall-clock.js +84 -0
- package/office/assets/wall.js +53 -0
- package/office/assets/water-cooler.js +146 -0
- package/office/assets/whiteboard.js +115 -0
- package/office/assets.js +3 -431
- package/office/builder.js +791 -355
- package/office/campus-env.js +1012 -1119
- package/office/environment.js +2 -0
- package/office/gallery.js +997 -0
- package/office/index.js +165 -61
- package/office/navigation.js +173 -152
- package/office/player.js +178 -68
- package/office/robot-character.js +272 -0
- package/office/spectator-camera.js +33 -10
- package/office/state.js +2 -0
- package/office/world-save.js +35 -4
- package/package.json +57 -3
- package/providers/comfyui.js +383 -0
- package/providers/dalle.js +79 -0
- package/providers/gemini.js +181 -0
- package/providers/ollama.js +184 -0
- package/providers/replicate.js +115 -0
- package/providers/zai.js +183 -0
- package/runtime-descriptor.js +270 -0
- package/scripts/check-agent-contract-advisory.js +132 -0
- package/scripts/check-api-agent-parity.js +277 -0
- package/scripts/check-autonomy-v2-decision.js +207 -0
- package/scripts/check-autonomy-v2-execution.js +588 -0
- package/scripts/check-autonomy-v2-watchdog.js +224 -0
- package/scripts/check-branch-fork-snapshot.js +337 -0
- package/scripts/check-branch-isolation.js +787 -0
- package/scripts/check-branch-semantics.js +139 -0
- package/scripts/check-dashboard-control-plane.js +1304 -0
- package/scripts/check-docs-onboarding.js +490 -0
- package/scripts/check-event-schema.js +276 -0
- package/scripts/check-evidence-completion.js +239 -0
- package/scripts/check-invariants.js +992 -0
- package/scripts/check-lifecycle-hooks.js +525 -0
- package/scripts/check-managed-team-integration.js +166 -0
- package/scripts/check-markdown-workspace-export.js +548 -0
- package/scripts/check-markdown-workspace-safety.js +347 -0
- package/scripts/check-markdown-workspace.js +136 -0
- package/scripts/check-message-replay.js +429 -0
- package/scripts/check-migration-hardening.js +300 -0
- package/scripts/check-performance-indexing.js +272 -0
- package/scripts/check-provider-capabilities.js +316 -0
- package/scripts/check-runtime-contract.js +109 -0
- package/scripts/check-session-aware-context.js +172 -0
- package/scripts/check-session-lifecycle.js +210 -0
- package/scripts/export-markdown-workspace.js +84 -0
- package/scripts/fixtures/message-replay/clean.jsonl +2 -0
- package/scripts/fixtures/message-replay/corrupt-correction-payload.jsonl +1 -0
- package/scripts/fixtures/message-replay/corrupt-jsonl.jsonl +1 -0
- package/scripts/fixtures/message-replay/corrupt-payload.jsonl +1 -0
- package/scripts/fixtures/message-replay/out-of-order.jsonl +2 -0
- package/scripts/migrate-legacy-to-canonical.js +201 -0
- package/scripts/run-verification-suite.js +242 -0
- package/scripts/sync-packaged-docs.js +69 -0
- package/server.js +9546 -7216
- package/state/agents.js +161 -0
- package/state/canonical.js +3068 -0
- package/state/dashboard-queries.js +441 -0
- package/state/evidence.js +56 -0
- package/state/io.js +69 -0
- package/state/markdown-workspace.js +951 -0
- package/state/messages.js +669 -0
- package/state/sessions.js +683 -0
- package/state/tasks-workflows.js +92 -0
- package/templates/debate.json +2 -2
- package/templates/managed.json +4 -4
- package/templates/pair.json +2 -2
- package/templates/review.json +2 -2
- package/templates/team.json +3 -3
package/office/builder.js
CHANGED
|
@@ -1,355 +1,791 @@
|
|
|
1
|
-
// builder.js —
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
var
|
|
12
|
-
var
|
|
13
|
-
var
|
|
14
|
-
var
|
|
15
|
-
var
|
|
16
|
-
var
|
|
17
|
-
var
|
|
18
|
-
var
|
|
19
|
-
var
|
|
20
|
-
var
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
_active
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// =====================
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
var
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
var
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
}
|