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