@compilr-dev/sdk 0.29.8 → 0.30.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.
@@ -0,0 +1,843 @@
1
+ /**
2
+ * 3D scene contract — the `scene` canvas type (3d-canvas-spec §2).
3
+ *
4
+ * A scene is VALIDATED JSON DATA, never code: a list of primitives in metres. Agents write
5
+ * it through the scene tools; the host draws it (three.js in Desktop's renderer). This module
6
+ * is the contract both sides bind to — types, limits, validation, normalisation and the few
7
+ * pieces of geometry (lift, bounds, fit) that must be ONE function everywhere.
8
+ *
9
+ * ⚠️ Renderer-safe on purpose: no imports at all. Desktop's renderer imports this through
10
+ * `@compilr-dev/sdk/canvas`; one node import here breaks its bundle (see
11
+ * `tests/canvas-subpath.test.ts`).
12
+ */
13
+ // =============================================================================
14
+ // Types
15
+ // =============================================================================
16
+ export const SCENE_VERSION = 1;
17
+ export const SCENE_OBJECT_TYPES = [
18
+ 'box',
19
+ 'sphere',
20
+ 'cylinder',
21
+ 'cone',
22
+ 'torus',
23
+ 'extrude',
24
+ ];
25
+ // =============================================================================
26
+ // Limits and defaults (§2.3, §2.4, Q-1, Q-14)
27
+ // =============================================================================
28
+ export const SCENE_MAX_OBJECTS = 500;
29
+ export const SCENE_MAX_BYTES = 512 * 1024;
30
+ /** Max objects in one scene_add_object batch (Q-1). */
31
+ export const SCENE_MAX_BATCH = 50;
32
+ export const SCENE_MAX_COORD = 1000;
33
+ export const SCENE_SIZE_MIN = 0.01;
34
+ export const SCENE_SIZE_MAX = 500;
35
+ export const SCENE_RADIUS_MIN = 0.01;
36
+ export const SCENE_RADIUS_MAX = 250;
37
+ export const SCENE_TUBE_MIN = 0.005;
38
+ export const SCENE_POINTS_MIN = 3;
39
+ export const SCENE_POINTS_MAX = 256;
40
+ export const SCENE_MAX_LIGHTS = 4;
41
+ export const SCENE_MAX_SHADOW_LIGHTS = 2;
42
+ export const SCENE_LIGHT_INTENSITY_MAX = 10;
43
+ export const SCENE_MAX_GROUP_DEPTH = 8;
44
+ export const SCENE_NAME_MAX = 80;
45
+ export const SCENE_OPACITY_MIN = 0.05;
46
+ export const SCENE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,47}$/;
47
+ /** Object colours never read as state (README) — a neutral palette, filled by insertion index. */
48
+ export const SCENE_NEUTRAL_PALETTE = [
49
+ '#D6D3CE',
50
+ '#A8A29E',
51
+ '#57534E',
52
+ '#C9B8A3',
53
+ '#8FA3B0',
54
+ '#B8B2A8',
55
+ '#7C8A6E',
56
+ ];
57
+ /** Accessible names for the palette swatches, same order (Q-10). */
58
+ export const SCENE_PALETTE_NAMES = [
59
+ 'Stone',
60
+ 'Taupe',
61
+ 'Charcoal',
62
+ 'Sand',
63
+ 'Slate',
64
+ 'Ash',
65
+ 'Moss',
66
+ ];
67
+ export const SCENE_DEFAULT_MATERIAL = {
68
+ roughness: 0.78,
69
+ metalness: 0.02,
70
+ opacity: 1,
71
+ };
72
+ /** The reference camera, used for an empty scene. */
73
+ export const SCENE_DEFAULT_CAMERA = {
74
+ position: [7, 5.5, 8],
75
+ target: [0, 0.6, 0],
76
+ };
77
+ /** Vertical field of view of the viewer camera, degrees. */
78
+ export const SCENE_CAMERA_FOV = 38;
79
+ /** A new, empty scene. */
80
+ export function emptyScene(name) {
81
+ return {
82
+ version: SCENE_VERSION,
83
+ name: name.trim().slice(0, SCENE_NAME_MAX) || 'Scene',
84
+ units: 'm',
85
+ objects: [],
86
+ };
87
+ }
88
+ /**
89
+ * Defaults for a freshly added shape of each type (the reference's fallbacks), for the
90
+ * inspector's "Add a shape" menu. Position is the origin.
91
+ */
92
+ export function defaultObjectFor(type, id) {
93
+ const base = { id, position: [0, 0, 0] };
94
+ switch (type) {
95
+ case 'box':
96
+ return { ...base, type, size: [1, 1, 1] };
97
+ case 'sphere':
98
+ return { ...base, type, radius: 0.5 };
99
+ case 'cylinder':
100
+ return { ...base, type, radius: 0.5, height: 1 };
101
+ case 'cone':
102
+ return { ...base, type, radius: 0.5, height: 1 };
103
+ case 'torus':
104
+ return { ...base, type, radius: 0.6, tube: 0.18 };
105
+ case 'extrude':
106
+ return {
107
+ ...base,
108
+ type,
109
+ points: [
110
+ [0, 0],
111
+ [1, 0],
112
+ [1, 1],
113
+ [0, 1],
114
+ ],
115
+ height: 1,
116
+ };
117
+ }
118
+ }
119
+ /** The stored form: pretty-printed, because agents read and cite it (§4.1). */
120
+ export function serializeScene(scene) {
121
+ return JSON.stringify(scene, null, 2);
122
+ }
123
+ /** `slug(title).scene.json` — the display file name and the JSON export's default name. */
124
+ export function sceneFileName(title) {
125
+ const slug = title
126
+ .toLowerCase()
127
+ .replace(/[^a-z0-9]+/g, '-')
128
+ .replace(/^-+|-+$/g, '');
129
+ return `${slug || 'scene'}.scene.json`;
130
+ }
131
+ /** UTF-8 byte length without TextEncoder (keeps this module free of DOM/node typings). */
132
+ export function utf8Bytes(s) {
133
+ let n = 0;
134
+ for (let i = 0; i < s.length; i++) {
135
+ const c = s.charCodeAt(i);
136
+ if (c < 0x80)
137
+ n += 1;
138
+ else if (c < 0x800)
139
+ n += 2;
140
+ else if (c >= 0xd800 && c <= 0xdbff) {
141
+ n += 4;
142
+ i++;
143
+ }
144
+ else
145
+ n += 3;
146
+ }
147
+ return n;
148
+ }
149
+ const TOP_KEYS = new Set(['version', 'name', 'units', 'camera', 'lights', 'objects']);
150
+ const BASE_KEYS = [
151
+ 'id',
152
+ 'name',
153
+ 'type',
154
+ 'position',
155
+ 'rotation',
156
+ 'color',
157
+ 'group',
158
+ 'material',
159
+ 'locked',
160
+ ];
161
+ const TYPE_KEYS = {
162
+ box: ['size'],
163
+ sphere: ['radius'],
164
+ cylinder: ['radius', 'height'],
165
+ cone: ['radius', 'height'],
166
+ torus: ['radius', 'tube'],
167
+ extrude: ['points', 'height'],
168
+ };
169
+ /** How each type is described in errors (what fields it takes). */
170
+ const TYPE_SHAPE = {
171
+ box: 'box takes size [w, h, d]',
172
+ sphere: 'sphere takes radius',
173
+ cylinder: 'cylinder takes radius and height',
174
+ cone: 'cone takes radius and height',
175
+ torus: 'torus takes radius and tube',
176
+ extrude: 'extrude takes points [[x, z], …] and height',
177
+ };
178
+ const MATERIAL_KEYS = new Set(['roughness', 'metalness', 'opacity']);
179
+ const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
180
+ /** Short, stable number rendering for messages and summaries. */
181
+ export function fmtNum(n) {
182
+ return String(Math.round(n * 1000) / 1000);
183
+ }
184
+ export function fmtVec(v) {
185
+ return `[${v.map(fmtNum).join(', ')}]`;
186
+ }
187
+ function show(v) {
188
+ if (typeof v === 'number')
189
+ return Number.isFinite(v) ? fmtNum(v) : String(v);
190
+ if (typeof v === 'string')
191
+ return JSON.stringify(v);
192
+ if (v === undefined)
193
+ return 'missing';
194
+ try {
195
+ const s = JSON.stringify(v);
196
+ return s.length > 60 ? `${s.slice(0, 57)}...` : s;
197
+ }
198
+ catch {
199
+ return '(unprintable)';
200
+ }
201
+ }
202
+ function isRecord(v) {
203
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
204
+ }
205
+ /** A finite number within [min, max]; pushes an error naming the path otherwise. */
206
+ function checkNumber(v, path, min, max, rule, errors) {
207
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
208
+ errors.push(`${path} = ${show(v)} — must be a finite number (${rule}).`);
209
+ return false;
210
+ }
211
+ if (v < min || v > max) {
212
+ errors.push(`${path} = ${fmtNum(v)} — ${rule}.`);
213
+ return false;
214
+ }
215
+ return true;
216
+ }
217
+ function checkVec(v, path, len, min, max, rule, errors) {
218
+ if (!Array.isArray(v) || v.length !== len) {
219
+ errors.push(`${path} = ${show(v)} — must be an array of ${String(len)} numbers (${rule}).`);
220
+ return false;
221
+ }
222
+ let ok = true;
223
+ v.forEach((n, i) => {
224
+ if (!checkNumber(n, `${path}[${String(i)}]`, min, max, rule, errors))
225
+ ok = false;
226
+ });
227
+ return ok;
228
+ }
229
+ const COORD_RULE = `coordinates are metres within ±${String(SCENE_MAX_COORD)}`;
230
+ const SIZE_RULE = `sizes are ${fmtNum(SCENE_SIZE_MIN)}–${fmtNum(SCENE_SIZE_MAX)} m. Heights are in metres, not cm`;
231
+ const RADIUS_RULE = `radii are ${fmtNum(SCENE_RADIUS_MIN)}–${fmtNum(SCENE_RADIUS_MAX)} m`;
232
+ function checkCoord(v, path, errors) {
233
+ return checkVec(v, path, 3, -SCENE_MAX_COORD, SCENE_MAX_COORD, COORD_RULE, errors);
234
+ }
235
+ /** Drop a closing duplicate of the first point (§2.3: dropped, not rejected). */
236
+ export function openOutline(points) {
237
+ if (points.length >= 2) {
238
+ const a = points[0];
239
+ const b = points[points.length - 1];
240
+ if (a[0] === b[0] && a[1] === b[1])
241
+ return points.slice(0, -1);
242
+ }
243
+ return points;
244
+ }
245
+ const EPS = 1e-9;
246
+ function orient(a, b, c) {
247
+ const v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
248
+ return Math.abs(v) < EPS ? 0 : v > 0 ? 1 : -1;
249
+ }
250
+ function onSegment(a, b, p) {
251
+ return (Math.min(a[0], b[0]) - EPS <= p[0] &&
252
+ p[0] <= Math.max(a[0], b[0]) + EPS &&
253
+ Math.min(a[1], b[1]) - EPS <= p[1] &&
254
+ p[1] <= Math.max(a[1], b[1]) + EPS);
255
+ }
256
+ function segmentsIntersect(p1, p2, p3, p4) {
257
+ const d1 = orient(p3, p4, p1);
258
+ const d2 = orient(p3, p4, p2);
259
+ const d3 = orient(p1, p2, p3);
260
+ const d4 = orient(p1, p2, p4);
261
+ if (d1 !== d2 && d3 !== d4 && d1 !== 0 && d2 !== 0 && d3 !== 0 && d4 !== 0)
262
+ return true;
263
+ if (d1 === 0 && onSegment(p3, p4, p1))
264
+ return true;
265
+ if (d2 === 0 && onSegment(p3, p4, p2))
266
+ return true;
267
+ if (d3 === 0 && onSegment(p1, p2, p3))
268
+ return true;
269
+ if (d4 === 0 && onSegment(p1, p2, p4))
270
+ return true;
271
+ return false;
272
+ }
273
+ /** Adjacent edges a→b, b→c fold back onto each other (collinear, c heading back over a→b). */
274
+ function foldsBack(a, b, c) {
275
+ if (orient(a, b, c) !== 0)
276
+ return false;
277
+ const dot = (a[0] - b[0]) * (c[0] - b[0]) + (a[1] - b[1]) * (c[1] - b[1]);
278
+ return dot > 0;
279
+ }
280
+ /**
281
+ * Is the (open) outline a simple polygon? O(n²) segment test — ExtrudeGeometry's
282
+ * triangulation silently breaks on self-intersection. Returns the first offending edge pair.
283
+ */
284
+ export function findSelfIntersection(points) {
285
+ const n = points.length;
286
+ for (let i = 0; i < n; i++) {
287
+ const a = points[i];
288
+ const b = points[(i + 1) % n];
289
+ // Adjacent edge (shares b): only a fold-back overlaps.
290
+ if (foldsBack(a, b, points[(i + 2) % n]))
291
+ return [i, (i + 1) % n];
292
+ for (let j = i + 2; j < n; j++) {
293
+ if (i === 0 && j === n - 1)
294
+ continue; // adjacent through the closing vertex
295
+ if (segmentsIntersect(a, b, points[j], points[(j + 1) % n]))
296
+ return [i, j];
297
+ }
298
+ }
299
+ return null;
300
+ }
301
+ function objectLabel(i, raw) {
302
+ return typeof raw.id === 'string' && raw.id
303
+ ? `objects[${String(i)}] ("${raw.id}")`
304
+ : `objects[${String(i)}]`;
305
+ }
306
+ function validateObject(raw, i, errors) {
307
+ const at = `objects[${String(i)}]`;
308
+ if (!isRecord(raw)) {
309
+ errors.push(`${at} = ${show(raw)} — each object must be a JSON object.`);
310
+ return;
311
+ }
312
+ const label = objectLabel(i, raw);
313
+ const t = raw.type;
314
+ if (t === 'wall') {
315
+ errors.push(`${label}.type = "wall" — use type "extrude" with points [[x,z],…] and height for walls.`);
316
+ return;
317
+ }
318
+ if (typeof t !== 'string' || !SCENE_OBJECT_TYPES.includes(t)) {
319
+ errors.push(`${label}.type = ${show(t)} — must be one of ${SCENE_OBJECT_TYPES.join(', ')}.`);
320
+ return;
321
+ }
322
+ const type = t;
323
+ // Unknown keys: rejected, because they would round-trip silently and read as honoured.
324
+ const allowed = new Set([...BASE_KEYS, ...TYPE_KEYS[type]]);
325
+ for (const k of Object.keys(raw)) {
326
+ if (!allowed.has(k)) {
327
+ errors.push(`${label}.${k} is not a ${type} field — ${TYPE_SHAPE[type]}.`);
328
+ }
329
+ }
330
+ if (typeof raw.id !== 'string' || !SCENE_ID_PATTERN.test(raw.id)) {
331
+ errors.push(`${at}.id = ${show(raw.id)} — ids are 1–48 chars of lowercase letters, digits, "-" or "_", starting with a letter or digit.`);
332
+ }
333
+ if (raw.name !== undefined) {
334
+ if (typeof raw.name !== 'string' || raw.name.length < 1 || raw.name.length > SCENE_NAME_MAX) {
335
+ errors.push(`${label}.name = ${show(raw.name)} — a string of 1–${String(SCENE_NAME_MAX)} chars.`);
336
+ }
337
+ }
338
+ checkCoord(raw.position, `${label}.position`, errors);
339
+ if (raw.rotation !== undefined) {
340
+ checkVec(raw.rotation, `${label}.rotation`, 3, -Number.MAX_VALUE, Number.MAX_VALUE, 'rotation is degrees [x, y, z]; rotate about Y — x/z tilt breaks the floor rule', errors);
341
+ }
342
+ if (raw.color !== undefined) {
343
+ if (typeof raw.color !== 'string' || !HEX_RE.test(raw.color)) {
344
+ errors.push(`${label}.color = ${show(raw.color)} — colours are "#RGB" or "#RRGGBB" hex.`);
345
+ }
346
+ }
347
+ if (raw.group !== undefined && typeof raw.group !== 'string') {
348
+ errors.push(`${label}.group = ${show(raw.group)} — the parent object's id (a string).`);
349
+ }
350
+ if (raw.locked !== undefined && typeof raw.locked !== 'boolean') {
351
+ errors.push(`${label}.locked = ${show(raw.locked)} — true or false.`);
352
+ }
353
+ if (raw.material !== undefined) {
354
+ if (!isRecord(raw.material)) {
355
+ errors.push(`${label}.material = ${show(raw.material)} — { roughness?, metalness?, opacity? }.`);
356
+ }
357
+ else {
358
+ const m = raw.material;
359
+ for (const k of Object.keys(m)) {
360
+ if (!MATERIAL_KEYS.has(k)) {
361
+ errors.push(`${label}.material.${k} is not a material field — roughness, metalness, opacity.`);
362
+ }
363
+ }
364
+ if (m.roughness !== undefined)
365
+ checkNumber(m.roughness, `${label}.material.roughness`, 0, 1, 'roughness is 0–1', errors);
366
+ if (m.metalness !== undefined)
367
+ checkNumber(m.metalness, `${label}.material.metalness`, 0, 1, 'metalness is 0–1', errors);
368
+ if (m.opacity !== undefined)
369
+ checkNumber(m.opacity, `${label}.material.opacity`, SCENE_OPACITY_MIN, 1, `opacity is ${fmtNum(SCENE_OPACITY_MIN)}–1`, errors);
370
+ }
371
+ }
372
+ // Type-specific dimensions.
373
+ switch (type) {
374
+ case 'box':
375
+ checkVec(raw.size, `${label}.size`, 3, SCENE_SIZE_MIN, SCENE_SIZE_MAX, SIZE_RULE, errors);
376
+ break;
377
+ case 'sphere':
378
+ checkNumber(raw.radius, `${label}.radius`, SCENE_RADIUS_MIN, SCENE_RADIUS_MAX, RADIUS_RULE, errors);
379
+ break;
380
+ case 'cylinder':
381
+ case 'cone':
382
+ checkNumber(raw.radius, `${label}.radius`, SCENE_RADIUS_MIN, SCENE_RADIUS_MAX, RADIUS_RULE, errors);
383
+ checkNumber(raw.height, `${label}.height`, SCENE_SIZE_MIN, SCENE_SIZE_MAX, SIZE_RULE, errors);
384
+ break;
385
+ case 'torus': {
386
+ const rOk = checkNumber(raw.radius, `${label}.radius`, SCENE_RADIUS_MIN, SCENE_RADIUS_MAX, RADIUS_RULE, errors);
387
+ const maxTube = rOk ? raw.radius : SCENE_RADIUS_MAX;
388
+ checkNumber(raw.tube, `${label}.tube`, SCENE_TUBE_MIN, maxTube, `tube is ${fmtNum(SCENE_TUBE_MIN)} m up to the ring radius (a tube wider than its ring self-intersects)`, errors);
389
+ break;
390
+ }
391
+ case 'extrude':
392
+ checkNumber(raw.height, `${label}.height`, SCENE_SIZE_MIN, SCENE_SIZE_MAX, SIZE_RULE, errors);
393
+ validatePoints(raw.points, `${label}.points`, errors);
394
+ break;
395
+ }
396
+ }
397
+ function validatePoints(v, path, errors) {
398
+ if (!Array.isArray(v)) {
399
+ errors.push(`${path} = ${show(v)} — an array of [x, z] plan points.`);
400
+ return;
401
+ }
402
+ const before = errors.length;
403
+ v.forEach((p, i) => {
404
+ checkVec(p, `${path}[${String(i)}]`, 2, -SCENE_MAX_COORD, SCENE_MAX_COORD, COORD_RULE, errors);
405
+ });
406
+ if (errors.length > before)
407
+ return;
408
+ const pts = openOutline(v);
409
+ if (pts.length < SCENE_POINTS_MIN || pts.length > SCENE_POINTS_MAX) {
410
+ errors.push(`${path} has ${String(pts.length)} points — an outline needs ${String(SCENE_POINTS_MIN)}–${String(SCENE_POINTS_MAX)} (a closing repeat of the first point is not counted).`);
411
+ return;
412
+ }
413
+ for (let i = 0; i + 1 < pts.length; i++) {
414
+ if (pts[i][0] === pts[i + 1][0] && pts[i][1] === pts[i + 1][1]) {
415
+ errors.push(`${path}[${String(i + 1)}] repeats the previous point ${fmtVec(pts[i])} — remove it.`);
416
+ return;
417
+ }
418
+ }
419
+ const hit = findSelfIntersection(pts);
420
+ if (hit) {
421
+ errors.push(`${path} is not a simple polygon — edge ${String(hit[0])}→${String(hit[0] + 1)} crosses or overlaps edge ${String(hit[1])}→${String(hit[1] + 1)}. Outlines must not self-intersect; split it into two extrudes.`);
422
+ }
423
+ }
424
+ function validateGroups(objects, errors) {
425
+ const byId = new Map();
426
+ for (const o of objects)
427
+ if (typeof o.id === 'string')
428
+ byId.set(o.id, o);
429
+ objects.forEach((o, i) => {
430
+ if (typeof o.group !== 'string')
431
+ return;
432
+ const label = objectLabel(i, o);
433
+ if (o.group === o.id) {
434
+ errors.push(`${label}.group = "${o.group}" — an object cannot be its own parent.`);
435
+ return;
436
+ }
437
+ if (!byId.has(o.group)) {
438
+ errors.push(`${label}.group = "${o.group}" — no object with that id in the scene.`);
439
+ return;
440
+ }
441
+ // Walk up: cycle / depth.
442
+ const seen = new Set([String(o.id)]);
443
+ let cur = o.group;
444
+ let depth = 0;
445
+ while (cur !== undefined) {
446
+ depth++;
447
+ if (seen.has(cur)) {
448
+ errors.push(`${label}.group = "${o.group}" — the group chain loops back through "${cur}".`);
449
+ return;
450
+ }
451
+ if (depth > SCENE_MAX_GROUP_DEPTH) {
452
+ errors.push(`${label}.group — groups nest at most ${String(SCENE_MAX_GROUP_DEPTH)} deep.`);
453
+ return;
454
+ }
455
+ seen.add(cur);
456
+ const parent = byId.get(cur);
457
+ cur = parent && typeof parent.group === 'string' ? parent.group : undefined;
458
+ }
459
+ });
460
+ }
461
+ function validateLights(v, errors) {
462
+ if (!Array.isArray(v)) {
463
+ errors.push(`lights = ${show(v)} — an array of { type: "sun" | "ambient", … }.`);
464
+ return;
465
+ }
466
+ if (v.length > SCENE_MAX_LIGHTS) {
467
+ errors.push(`lights has ${String(v.length)} entries (max ${String(SCENE_MAX_LIGHTS)}).`);
468
+ }
469
+ let shadows = 0;
470
+ v.forEach((l, i) => {
471
+ const at = `lights[${String(i)}]`;
472
+ if (!isRecord(l)) {
473
+ errors.push(`${at} = ${show(l)} — must be a JSON object.`);
474
+ return;
475
+ }
476
+ if (l.type === 'sun') {
477
+ for (const k of Object.keys(l)) {
478
+ if (!['type', 'position', 'intensity', 'castShadow'].includes(k))
479
+ errors.push(`${at}.${k} is not a sun-light field — position, intensity, castShadow.`);
480
+ }
481
+ checkCoord(l.position, `${at}.position`, errors);
482
+ if (l.castShadow !== undefined && typeof l.castShadow !== 'boolean') {
483
+ errors.push(`${at}.castShadow = ${show(l.castShadow)} — true or false.`);
484
+ }
485
+ if (l.castShadow === true)
486
+ shadows++;
487
+ }
488
+ else if (l.type === 'ambient') {
489
+ for (const k of Object.keys(l)) {
490
+ if (!['type', 'intensity'].includes(k))
491
+ errors.push(`${at}.${k} is not an ambient-light field — intensity.`);
492
+ }
493
+ }
494
+ else {
495
+ errors.push(`${at}.type = ${show(l.type)} — "sun" or "ambient".`);
496
+ return;
497
+ }
498
+ if (l.intensity !== undefined) {
499
+ checkNumber(l.intensity, `${at}.intensity`, 0, SCENE_LIGHT_INTENSITY_MAX, `intensity is 0–${String(SCENE_LIGHT_INTENSITY_MAX)}`, errors);
500
+ }
501
+ });
502
+ if (shadows > SCENE_MAX_SHADOW_LIGHTS) {
503
+ errors.push(`lights: ${String(shadows)} lights cast shadows (max ${String(SCENE_MAX_SHADOW_LIGHTS)} — each costs a render pass).`);
504
+ }
505
+ }
506
+ /**
507
+ * Validate an untrusted scene (agent JSON, a stored row, an import). Every error names the
508
+ * field path and what is allowed. On success the returned scene is a deep copy of the input
509
+ * (not yet normalised — see `normalizeScene`).
510
+ */
511
+ export function validateScene(input) {
512
+ const errors = [];
513
+ if (!isRecord(input)) {
514
+ return {
515
+ ok: false,
516
+ errors: ['The scene must be a JSON object { version, name, units, objects }.'],
517
+ };
518
+ }
519
+ for (const k of Object.keys(input)) {
520
+ if (!TOP_KEYS.has(k)) {
521
+ errors.push(`${k} is not a scene field — version, name, units, camera, lights, objects.`);
522
+ }
523
+ }
524
+ if (input.version !== SCENE_VERSION) {
525
+ errors.push(`version = ${show(input.version)} — must be ${String(SCENE_VERSION)}.`);
526
+ }
527
+ if (input.units !== 'm') {
528
+ errors.push(`units = ${show(input.units)} — only "m" (metres) in v1.`);
529
+ }
530
+ if (typeof input.name !== 'string' ||
531
+ input.name.trim().length < 1 ||
532
+ input.name.length > SCENE_NAME_MAX) {
533
+ errors.push(`name = ${show(input.name)} — a string of 1–${String(SCENE_NAME_MAX)} chars.`);
534
+ }
535
+ if (input.camera !== undefined) {
536
+ if (!isRecord(input.camera)) {
537
+ errors.push(`camera = ${show(input.camera)} — { position: [x, y, z], target: [x, y, z] }.`);
538
+ }
539
+ else {
540
+ for (const k of Object.keys(input.camera)) {
541
+ if (k !== 'position' && k !== 'target')
542
+ errors.push(`camera.${k} is not a camera field — position, target.`);
543
+ }
544
+ checkCoord(input.camera.position, 'camera.position', errors);
545
+ checkCoord(input.camera.target, 'camera.target', errors);
546
+ }
547
+ }
548
+ if (input.lights !== undefined)
549
+ validateLights(input.lights, errors);
550
+ if (!Array.isArray(input.objects)) {
551
+ errors.push(`objects = ${show(input.objects)} — must be an array.`);
552
+ return { ok: false, errors };
553
+ }
554
+ const objects = input.objects;
555
+ if (objects.length > SCENE_MAX_OBJECTS) {
556
+ errors.push(`Scene would have ${String(objects.length)} objects (max ${String(SCENE_MAX_OBJECTS)}). Merge repeated parts or split the scene into two canvases (scene_create).`);
557
+ return { ok: false, errors };
558
+ }
559
+ const seen = new Map();
560
+ objects.forEach((o, i) => {
561
+ validateObject(o, i, errors);
562
+ if (isRecord(o) && typeof o.id === 'string') {
563
+ const prev = seen.get(o.id);
564
+ if (prev !== undefined) {
565
+ errors.push(`objects[${String(i)}].id = "${o.id}" — duplicate of objects[${String(prev)}]; ids must be unique.`);
566
+ }
567
+ else
568
+ seen.set(o.id, i);
569
+ }
570
+ });
571
+ validateGroups(objects.filter(isRecord), errors);
572
+ if (errors.length === 0) {
573
+ const text = JSON.stringify(input, null, 2);
574
+ const bytes = utf8Bytes(text);
575
+ if (bytes > SCENE_MAX_BYTES) {
576
+ errors.push(`The scene serialises to ${String(Math.round(bytes / 1024))} KB (max ${String(SCENE_MAX_BYTES / 1024)} KB). Simplify outlines or split the scene into two canvases (scene_create).`);
577
+ }
578
+ }
579
+ if (errors.length > 0)
580
+ return { ok: false, errors };
581
+ return { ok: true, scene: JSON.parse(JSON.stringify(input)) };
582
+ }
583
+ // =============================================================================
584
+ // Normalisation (what the writer owns)
585
+ // =============================================================================
586
+ /** '#abc' / '#aabbcc' → '#AABBCC'. Assumes a valid hex (validated first). */
587
+ export function normalizeColor(hex) {
588
+ const h = hex.slice(1);
589
+ const full = h.length === 3
590
+ ? h
591
+ .split('')
592
+ .map((c) => c + c)
593
+ .join('')
594
+ : h;
595
+ return `#${full.toUpperCase()}`;
596
+ }
597
+ /** Degrees → (−180, 180]. */
598
+ export function normalizeAngle(deg) {
599
+ let a = deg % 360;
600
+ if (a <= -180)
601
+ a += 360;
602
+ else if (a > 180)
603
+ a -= 360;
604
+ // Avoid -0 in the stored JSON.
605
+ return a === 0 ? 0 : a;
606
+ }
607
+ /**
608
+ * Fill what the writer owns: upper-case #RRGGBB colours, the neutral default colour by
609
+ * insertion index (so the stored file is explicit and agent-readable), rotations wrapped to
610
+ * (−180, 180], closing duplicate points dropped. Never fills camera/lights — their absence
611
+ * means "fit" / "studio light". Pure; returns a new scene.
612
+ */
613
+ export function normalizeScene(scene) {
614
+ const out = JSON.parse(JSON.stringify(scene));
615
+ out.objects = out.objects.map((o, i) => {
616
+ const next = { ...o };
617
+ next.color =
618
+ next.color !== undefined
619
+ ? normalizeColor(next.color)
620
+ : SCENE_NEUTRAL_PALETTE[i % SCENE_NEUTRAL_PALETTE.length];
621
+ if (next.rotation) {
622
+ next.rotation = next.rotation.map(normalizeAngle);
623
+ }
624
+ if (next.type === 'extrude') {
625
+ next.points = openOutline(next.points);
626
+ }
627
+ return next;
628
+ });
629
+ return out;
630
+ }
631
+ // =============================================================================
632
+ // Geometry shared by viewer, fit and export
633
+ // =============================================================================
634
+ /**
635
+ * Half-height from the object's BOTTOM (its `position.y`) to its geometric centre, where the
636
+ * mesh sits. extrude is 0: its geometry already starts at the floor. ONE function — the
637
+ * viewer, the fit and the GLB export must agree.
638
+ */
639
+ export function liftFor(obj) {
640
+ switch (obj.type) {
641
+ case 'box':
642
+ return obj.size[1] / 2;
643
+ case 'sphere':
644
+ return obj.radius;
645
+ case 'cylinder':
646
+ case 'cone':
647
+ return obj.height / 2;
648
+ case 'torus':
649
+ return obj.tube;
650
+ case 'extrude':
651
+ return 0;
652
+ }
653
+ }
654
+ /** Local AABB of the mesh around its own origin (the pivot). */
655
+ function localBox(obj) {
656
+ switch (obj.type) {
657
+ case 'box': {
658
+ const [w, h, d] = obj.size;
659
+ return { min: [-w / 2, -h / 2, -d / 2], max: [w / 2, h / 2, d / 2] };
660
+ }
661
+ case 'sphere':
662
+ return {
663
+ min: [-obj.radius, -obj.radius, -obj.radius],
664
+ max: [obj.radius, obj.radius, obj.radius],
665
+ };
666
+ case 'cylinder':
667
+ case 'cone':
668
+ return {
669
+ min: [-obj.radius, -obj.height / 2, -obj.radius],
670
+ max: [obj.radius, obj.height / 2, obj.radius],
671
+ };
672
+ case 'torus': {
673
+ const r = obj.radius + obj.tube;
674
+ return { min: [-r, -obj.tube, -r], max: [r, obj.tube, r] };
675
+ }
676
+ case 'extrude': {
677
+ // Plan (x, z) → world (x, y∈[0,h], z) — the reference mapping (§2.2).
678
+ const xs = obj.points.map((p) => p[0]);
679
+ const zs = obj.points.map((p) => p[1]);
680
+ return {
681
+ min: [Math.min(...xs), 0, Math.min(...zs)],
682
+ max: [Math.max(...xs), obj.height, Math.max(...zs)],
683
+ };
684
+ }
685
+ }
686
+ }
687
+ const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
688
+ function mul(a, b) {
689
+ const r = new Array(16).fill(0);
690
+ for (let i = 0; i < 4; i++)
691
+ for (let j = 0; j < 4; j++)
692
+ for (let k = 0; k < 4; k++)
693
+ r[i * 4 + j] += a[i * 4 + k] * b[k * 4 + j];
694
+ return r;
695
+ }
696
+ function translate(x, y, z) {
697
+ return [1, 0, 0, x, 0, 1, 0, y, 0, 0, 1, z, 0, 0, 0, 1];
698
+ }
699
+ /** Euler XYZ (three.js default): R = Rx · Ry · Rz. */
700
+ function rotateXYZ(deg) {
701
+ const [x, y, z] = deg.map((d) => (d * Math.PI) / 180);
702
+ const cx = Math.cos(x), sx = Math.sin(x), cy = Math.cos(y), sy = Math.sin(y), cz = Math.cos(z), sz = Math.sin(z);
703
+ const rx = [1, 0, 0, 0, 0, cx, -sx, 0, 0, sx, cx, 0, 0, 0, 0, 1];
704
+ const ry = [cy, 0, sy, 0, 0, 1, 0, 0, -sy, 0, cy, 0, 0, 0, 0, 1];
705
+ const rz = [cz, -sz, 0, 0, sz, cz, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
706
+ return mul(mul(rx, ry), rz);
707
+ }
708
+ function apply(m, p) {
709
+ return [
710
+ m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3],
711
+ m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7],
712
+ m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11],
713
+ ];
714
+ }
715
+ /**
716
+ * An object's FRAME (Q-3): origin at its bottom point (`position`), rotated about its
717
+ * geometric centre. Children are placed in this frame, so a child's y = 0 sits at the
718
+ * parent's bottom. As matrices: T(position) · T(0, lift, 0) · R · T(0, −lift, 0).
719
+ * The mesh itself is frame · T(0, lift, 0) — for a root object that is exactly the
720
+ * reference: the mesh at position + lift, rotated about its centre.
721
+ */
722
+ function objectFrame(obj) {
723
+ const lift = liftFor(obj);
724
+ const [x, y, z] = obj.position;
725
+ return mul(mul(mul(translate(x, y, z), translate(0, lift, 0)), rotateXYZ(obj.rotation ?? [0, 0, 0])), translate(0, -lift, 0));
726
+ }
727
+ /**
728
+ * World matrix of each object's mesh (row-major 4×4), keyed by id, with group parenting
729
+ * applied. Assumes a validated scene (no cycles).
730
+ */
731
+ export function sceneWorldMatrices(scene) {
732
+ const byId = new Map(scene.objects.map((o) => [o.id, o]));
733
+ const frames = new Map();
734
+ const frameOf = (o, depth = 0) => {
735
+ const cached = frames.get(o.id);
736
+ if (cached)
737
+ return cached;
738
+ const parent = o.group !== undefined ? byId.get(o.group) : undefined;
739
+ const base = parent && depth <= SCENE_MAX_GROUP_DEPTH ? frameOf(parent, depth + 1) : IDENTITY;
740
+ const f = mul(base, objectFrame(o));
741
+ frames.set(o.id, f);
742
+ return f;
743
+ };
744
+ const out = new Map();
745
+ for (const o of scene.objects)
746
+ out.set(o.id, mul(frameOf(o), translate(0, liftFor(o), 0)));
747
+ return out;
748
+ }
749
+ /** World-space AABB of all objects (conservative for rotated shapes). null when empty. */
750
+ export function sceneBounds(scene) {
751
+ if (scene.objects.length === 0)
752
+ return null;
753
+ const mats = sceneWorldMatrices(scene);
754
+ const min = [Infinity, Infinity, Infinity];
755
+ const max = [-Infinity, -Infinity, -Infinity];
756
+ for (const o of scene.objects) {
757
+ const m = mats.get(o.id) ?? IDENTITY;
758
+ const b = localBox(o);
759
+ for (const cx of [b.min[0], b.max[0]])
760
+ for (const cy of [b.min[1], b.max[1]])
761
+ for (const cz of [b.min[2], b.max[2]]) {
762
+ const p = apply(m, [cx, cy, cz]);
763
+ for (let k = 0; k < 3; k++) {
764
+ if (p[k] < min[k])
765
+ min[k] = p[k];
766
+ if (p[k] > max[k])
767
+ max[k] = p[k];
768
+ }
769
+ }
770
+ }
771
+ return { min, max };
772
+ }
773
+ /** The reference view direction ([7, 5.5, 8], normalised). */
774
+ export const SCENE_CAMERA_DIRECTION = (() => {
775
+ const [x, y, z] = SCENE_DEFAULT_CAMERA.position;
776
+ const l = Math.hypot(x, y, z);
777
+ return [x / l, y / l, z / l];
778
+ })();
779
+ /**
780
+ * The camera used when a scene has none: look at the bounds' centre from the reference
781
+ * direction, at the distance that fits the bounding sphere in the vertical FOV, × 1.2.
782
+ * null bounds (empty scene) → the reference default.
783
+ */
784
+ export function fitCamera(bounds, fovDeg = SCENE_CAMERA_FOV) {
785
+ if (!bounds) {
786
+ return {
787
+ position: [...SCENE_DEFAULT_CAMERA.position],
788
+ target: [...SCENE_DEFAULT_CAMERA.target],
789
+ };
790
+ }
791
+ const c = [
792
+ (bounds.min[0] + bounds.max[0]) / 2,
793
+ (bounds.min[1] + bounds.max[1]) / 2,
794
+ (bounds.min[2] + bounds.max[2]) / 2,
795
+ ];
796
+ const radius = Math.max(0.25, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
797
+ const dist = (radius / Math.sin(((fovDeg / 2) * Math.PI) / 180)) * 1.2;
798
+ const d = SCENE_CAMERA_DIRECTION;
799
+ return { position: [c[0] + d[0] * dist, c[1] + d[1] * dist, c[2] + d[2] * dist], target: c };
800
+ }
801
+ /** The camera to view a scene with: its own, or the fit. */
802
+ export function sceneCamera(scene) {
803
+ return scene.camera ?? fitCamera(sceneBounds(scene));
804
+ }
805
+ /** One line: "12 objects, bounds 6.2 × 4 × 2.7 m" (W × D × H). */
806
+ export function describeScene(scene) {
807
+ const n = scene.objects.length;
808
+ const b = sceneBounds(scene);
809
+ const count = `${String(n)} object${n === 1 ? '' : 's'}`;
810
+ if (!b)
811
+ return count;
812
+ const w = b.max[0] - b.min[0];
813
+ const d = b.max[2] - b.min[2];
814
+ const h = b.max[1] - b.min[1];
815
+ return `${count}, bounds ${fmtNum(w)} × ${fmtNum(d)} × ${fmtNum(h)} m`;
816
+ }
817
+ /** The ids of an object's descendants (children, grandchildren, …), in scene order. */
818
+ export function descendantsOf(scene, id) {
819
+ const out = [];
820
+ const frontier = new Set([id]);
821
+ let grew = true;
822
+ while (grew) {
823
+ grew = false;
824
+ for (const o of scene.objects) {
825
+ if (o.group !== undefined && frontier.has(o.group) && !frontier.has(o.id)) {
826
+ frontier.add(o.id);
827
+ out.push(o.id);
828
+ grew = true;
829
+ }
830
+ }
831
+ }
832
+ return scene.objects.map((o) => o.id).filter((oid) => out.includes(oid));
833
+ }
834
+ /** Turn a display name (or type) into an id candidate: lowercase slug, ≤ 48 chars. */
835
+ export function slugifySceneId(text) {
836
+ const s = text
837
+ .toLowerCase()
838
+ .replace(/[^a-z0-9_-]+/g, '-')
839
+ .replace(/^[-_]+|[-_]+$/g, '')
840
+ .slice(0, 40)
841
+ .replace(/[-_]+$/g, '');
842
+ return s || 'object';
843
+ }