Repository navigation
Expand file tree
/
Copy pathmodel.ts
More file actions
648 lines (597 loc) · 36.1 KB
/
Copy pathmodel.ts
File metadata and controls
648 lines (597 loc) · 36.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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
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
104
105
106
107
108
109
110
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
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
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
// Shared types, constants, plan geometry and validators (SPEC.md: Shared modules > src/model.ts).
// Pure: no node: imports, because the browser bundles this file too.
// Everything is in plan inches: +x East, +y North, +z up; rotations are degrees CCW seen from above.
// ---------- types ----------
export type Vec2 = [number, number];
export type Vec3 = [number, number, number];
export interface Cutout { id: string; kind: "door" | "window" | "opening"; offset: number; width: number; sill: number; height: number; swing?: "in" | "out" | "none"; hinge?: "left" | "right" | "both" }
export interface Wall { id: string; start: Vec2; thickness: number; top: Vec2[]; cutouts: Cutout[] }
export interface CeilingPanel { id: string; points: Vec3[] }
export interface Material { color: string; texture?: string }
export interface Placement { id: string; asset: string; x: number; y: number; elevation?: number; rotation?: number; colors?: Record<string, string> }
export interface Overlay { file: string | null; yaw: number; offset: Vec3 }
export interface Room { walls: Wall[]; ceiling: CeilingPanel[]; trim: { baseboard: number; casing: number }; fixtures: Placement[]; materials: Record<string, Material>; overlay: Overlay; notes: string[]; defaultLayout?: string }
export interface View { position: Vec3; target: Vec3; fov: number }
export interface Layout { items: Placement[]; materials: Record<string, Material>; views: Record<string, View>; shop?: Shop }
// Shop: swappable real products. A slot names the base items it replaces; an option is the placements that replace them.
// presets: name -> { slot id -> option id }.
export interface ShopOption { id: string; label?: string; items: Placement[] }
export interface ShopSlot { id: string; label: string; items: string[]; options: ShopOption[]; decided?: boolean }
export interface Shop { slots: ShopSlot[]; presets?: Record<string, Record<string, string>> }
export interface Product { retailer: string; url: string; price: number; model: "retailer" | "mock"; title?: string; tier?: string;
pack?: number; priceChecked?: string; listedDims?: string; finish?: string; deck?: number; mockReason?: string }
export interface Asset { name: string; source: string; file: string; dims: Vec3; modelYaw?: 0 | 90 | 180 | 270; notes?: string; product?: Product }
export type Part = { name?: string; at: Vec3; rotate?: Vec3; material: string } & (
| { type: "box"; size: Vec3; radius?: number }
| { type: "cylinder"; radius: number; radiusTop?: number; height: number }
| { type: "lathe"; profile: Vec2[] }
| { type: "extrude"; profile: Vec2[]; depth: number });
export interface Parts { materials: Record<string, string>; parts: Part[] } // material name -> "#RRGGBB"
export interface TextureEntry { size: number; name: string } // image is textures/<id>.jpg
export interface Config { port: number; eyeHeight: number }
// glbSize: native glTF bbox extents [x, y, z] in METERS, before modelYaw (GLB/GLTF assets only)
export interface AssetEntry { asset: Asset; parts?: Parts; glbSize?: Vec3 }
export interface Bundle { roomName: string; layoutName: string; room: Room; layout: Layout;
assets: Record<string, AssetEntry>; textures: Record<string, TextureEntry>; errors: string[] }
/** One wall in plan: a = start, b = next wall's start, dir = unit(b - a), inward = right of travel (into the room). */
export interface WallFrame { wall: Wall; index: number; a: Vec2; b: Vec2; len: number; dir: Vec2; inward: Vec2; maxTop: number }
/** A keep-clear area in front of a cutout: plan polygon plus the z range it blocks. */
export interface Zone { poly: Vec2[]; z: [number, number] }
// ---------- constants (the only place these values live) ----------
export const IN_TO_M = 0.0254, M_TO_IN = 1 / 0.0254;
export const TOL = 0.25; // penetration/overshoot tolerance before anything is reported
export const FLAT_H = 1; // H <= FLAT_H => flat item
export const WALKWAY_MIN = 36; // walkway gaps below this are reported
export const WALKWAY_IGNORE = 12; // gaps below this are intentional (nightstand beside bed), not walkways
export const WALKWAY_MAX_ELEV = 30; // placements with elevation >= this are not floor obstacles
export const DOOR_CLEAR = 18; // clear-zone depth for swing "out"/"none" doors and openings
export const WINDOW_ZONE = 12; // zone depth in front of windows
export const GAP_REPORT = 36; // list walls within this distance of each item
export const FACING_GAP = 6; // a placement whose front is this close to a wall faces it (FACING)
export const TRIM_DEPTH = 0.75;
export const DEFAULT_THICKNESS = 4.5;
export const DEFAULT_COLORS = { walls: "#EDEAE4", floor: "#B89A76", ceiling: "#FFFFFF", trim: "#FFFFFF" };
export const ID_RE = /^[a-z0-9][a-z0-9-]*$/; // room, layout, view, asset, item and fixture ids
export const WALL_ID_RE = /^[A-Z]{1,2}[0-9]*$/;
// ---------- geometry (plan inches) ----------
const edges = (poly: Vec2[]) => poly.map((p, i): [Vec2, Vec2] => [p, poly[(i + 1) % poly.length]]);
const cross = (o: Vec2, a: Vec2, b: Vec2) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
export function wallFrames(room: Room): WallFrame[] {
return room.walls.map((wall, index) => {
const a = wall.start, b = room.walls[(index + 1) % room.walls.length].start;
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
const dir: Vec2 = [(b[0] - a[0]) / len, (b[1] - a[1]) / len];
return { wall, index, a, b, len, dir, inward: [dir[1], -dir[0]], maxTop: Math.max(...wall.top.map(t => t[1])) };
});
}
export function floorPolygon(room: Room): Vec2[] {
return room.walls.map(w => w.start);
}
/** Ceiling-line height at `s` along the wall: linear between points, constant past the ends, the higher side at a vertical step. */
export function topAt(wall: Wall, s: number): number {
const t = wall.top, on = t.filter(p => p[0] === s);
if (on.length) return Math.max(...on.map(p => p[1]));
const i = t.findIndex(p => p[0] > s);
if (i === 0) return t[0][1];
if (i < 0) return t[t.length - 1][1];
const [s0, h0] = t[i - 1], [s1, h1] = t[i];
return h0 + ((h1 - h0) * (s - s0)) / (s1 - s0);
}
export function topPolyline(wall: Wall, len: number): Vec2[] {
return [[0, topAt(wall, 0)], ...wall.top.filter(([s]) => s > 0 && s < len), [len, topAt(wall, len)]];
}
/** Positive = counterclockwise (y up). */
export function signedArea(poly: Vec2[]): number {
return edges(poly).reduce((sum, [p, q]) => sum + p[0] * q[1] - q[0] * p[1], 0) / 2;
}
/** Even-odd ray casting. */
export function pointInPolygon([x, y]: Vec2, poly: Vec2[]): boolean {
let inside = false;
for (const [[x1, y1], [x2, y2]] of edges(poly))
if ((y1 > y) !== (y2 > y) && x < x1 + ((y - y1) * (x2 - x1)) / (y2 - y1)) inside = !inside;
return inside;
}
export function distPointSegment(p: Vec2, a: Vec2, b: Vec2): number {
const dx = b[0] - a[0], dy = b[1] - a[1], l2 = dx * dx + dy * dy;
const t = l2 && Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2));
return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy);
}
export function distToEdges(p: Vec2, poly: Vec2[]): number {
return Math.min(...edges(poly).map(([a, b]) => distPointSegment(p, a, b)));
}
/** Distance between segments ab and cd (0 when they cross or touch). */
function segDist(a: Vec2, b: Vec2, c: Vec2, d: Vec2): number {
if (cross(a, b, c) * cross(a, b, d) < 0 && cross(c, d, a) * cross(c, d, b) < 0) return 0;
return Math.min(distPointSegment(a, c, d), distPointSegment(b, c, d), distPointSegment(c, a, b), distPointSegment(d, a, b));
}
/** O(n²): no two non-adjacent edges may cross or touch (within 1e-6 in), so zero-width "bridge" edges fail. */
export function isSimplePolygon(poly: Vec2[]): boolean {
const e = edges(poly), n = e.length;
if (n < 3) return false;
for (let i = 0; i < n; i++)
for (let j = i + 2; j < n - (i === 0 ? 1 : 0); j++)
if (segDist(e[i][0], e[i][1], e[j][0], e[j][1]) <= 1e-6) return false;
return true;
}
/** SAT for convex polygons: the smallest projection overlap over all edge normals. > 0 = penetration depth, <= 0 = separated. */
export function overlapDepth(a: Vec2[], b: Vec2[]): number {
let depth = Infinity;
for (const [p, q] of [...edges(a), ...edges(b)]) {
const nx = p[1] - q[1], ny = q[0] - p[0], l = Math.hypot(nx, ny);
if (!l) continue;
const pa = a.map(v => (v[0] * nx + v[1] * ny) / l), pb = b.map(v => (v[0] * nx + v[1] * ny) / l);
depth = Math.min(depth, Math.min(Math.max(...pa), Math.max(...pb)) - Math.max(Math.min(...pa), Math.min(...pb)));
}
return depth;
}
/** Minimum distance between convex polygons, 0 if they overlap. A 2-point array is a segment (a wall). */
export function polyDistance(a: Vec2[], b: Vec2[]): number {
if (pointInPolygon(a[0], b) || pointInPolygon(b[0], a)) return 0;
let d = Infinity;
for (const [p, q] of edges(a)) for (const [r, s] of edges(b)) d = Math.min(d, segDist(p, q, r, s));
return d;
}
/** The W x D footprint: 4 corners, CCW, starting front-left, rotated about (x, y). */
export function footprint(p: Placement, dims: Vec3): Vec2[] {
const r = ((p.rotation ?? 0) * Math.PI) / 180, c = Math.cos(r), s = Math.sin(r), w = dims[0] / 2, d = dims[1] / 2;
return ([[-w, -d], [w, -d], [w, d], [-w, d]] as Vec2[]).map(([u, v]): Vec2 => [p.x + u * c - v * s, p.y + u * s + v * c]);
}
export function zRange(p: Placement, dims: Vec3): [number, number] {
const e = p.elevation ?? 0;
return [e, e + dims[2]];
}
/** Unit Newell normal flipped so n.z >= 0, and the plane n·p = d. A panel with no area gets n = [0, 0, 0]. */
export function panelPlane(points: Vec3[]): { n: Vec3; d: number } {
let nx = 0, ny = 0, nz = 0;
points.forEach(([x, y, z], i) => {
const [x2, y2, z2] = points[(i + 1) % points.length];
nx += (y - y2) * (z + z2);
ny += (z - z2) * (x + x2);
nz += (x - x2) * (y + y2);
});
const l = (nz < 0 ? -1 : 1) * Math.hypot(nx, ny, nz) || 1;
const n: Vec3 = [nx / l, ny / l, nz / l];
return { n, d: points.reduce((sum, q) => sum + n[0] * q[0] + n[1] * q[1] + n[2] * q[2], 0) / points.length };
}
/** The riser rule: a (near-)vertical panel only closes the gap between two ceiling levels. ceilingZ, CEILING and SHELL ignore it. */
export function isRiser(panel: CeilingPanel): boolean {
return panelPlane(panel.points).n[2] < 0.1;
}
/** The panel's points in the 2D projection that drops its normal's largest component (plan x, y for ordinary panels). */
export function panelProjection(points: Vec3[], n = panelPlane(points).n): Vec2[] {
const [ax, ay, az] = n.map(Math.abs);
return points.map(([x, y, z]): Vec2 => (az >= ax && az >= ay ? [x, y] : ax >= ay ? [y, z] : [x, z]));
}
/** Lowest non-riser panel height over (x, y), counting panels within 0.5 in of the point. null = no ceiling there. */
export function ceilingZ(room: Room, x: number, y: number): number | null {
let z: number | null = null;
// ponytail: recomputes each panel's plane per call; cache per room if check ever gets slow on big rooms
for (const panel of room.ceiling) {
const { n, d } = panelPlane(panel.points);
const poly = panel.points.map(([px, py]): Vec2 => [px, py]);
if (n[2] < 0.1 || (!pointInPolygon([x, y], poly) && distToEdges([x, y], poly) > 0.5)) continue; // riser, or not over (x, y)
const h = (d - n[0] * x - n[1] * y) / n[2];
if (z === null || h < z) z = h;
}
return z;
}
/** The plan point `s` along the wall and `inward` into the room. */
const wallPoint = (f: WallFrame, s: number, inward = 0): Vec2 =>
[f.a[0] + f.dir[0] * s + f.inward[0] * inward, f.a[1] + f.dir[1] * s + f.inward[1] * inward];
const cutoutRect = (f: WallFrame, c: Cutout, depth: number): Vec2[] => [
wallPoint(f, c.offset), wallPoint(f, c.offset + c.width), wallPoint(f, c.offset + c.width, depth), wallPoint(f, c.offset, depth)];
/** Swing-in doors: a quarter disc per leaf. Other doors and openings: a DOOR_CLEAR-deep rectangle. Windows: none. */
export function doorZones(f: WallFrame, c: Cutout): Zone[] {
if (c.kind === "window") return [];
const z: [number, number] = [0, c.height];
if (c.kind === "opening" || c.swing !== "in") return [{ poly: cutoutRect(f, c, DOOR_CLEAR), z }];
// hinge at s, sweeping from the other jamb (sign = its direction along the wall) to inward, 17 points 5.625° apart
const leaf = (s: number, sign: number, r: number): Zone => ({
poly: [wallPoint(f, s), ...Array.from({ length: 17 }, (_, i) =>
wallPoint(f, s + sign * r * Math.cos((i * Math.PI) / 32), r * Math.sin((i * Math.PI) / 32)))],
z,
});
const left = c.offset, right = c.offset + c.width;
if (c.hinge === "both") return [leaf(left, 1, c.width / 2), leaf(right, -1, c.width / 2)];
return [c.hinge === "right" ? leaf(right, -1, c.width) : leaf(left, 1, c.width)];
}
export function windowZone(f: WallFrame, c: Cutout): Zone {
return { poly: cutoutRect(f, c, WINDOW_ZONE), z: [c.sill, c.sill + c.height] };
}
/** Layout entries replace whole room entries, key by key. */
export function mergedMaterials(room: Room, layout: Layout): Record<string, Material> {
return { ...room.materials, ...layout.materials };
}
/** `key` is a wall id, "floor", "ceiling" or "trim". A wall falls back to "walls", then to the defaults. */
export function surfaceMaterial(merged: Record<string, Material>, key: string): Material {
if (key === "floor" || key === "ceiling" || key === "trim") return merged[key] ?? { color: DEFAULT_COLORS[key] };
return merged[key] ?? merged.walls ?? { color: DEFAULT_COLORS.walls };
}
export function fmtIn(v: number): string {
return v.toFixed(1);
}
/** 144 -> 12' 0", 83.5 -> 6' 11.5", 143.96 -> 12' 0" (rounded before splitting, so never 11' 12.0"). */
export function fmtFtIn(v: number): string {
const r = Math.round(v * 10) / 10, ft = Math.floor(r / 12);
return `${ft}' ${(r - 12 * ft).toFixed(1).replace(/\.0$/, "")}"`;
}
/** Floor bbox size, ceiling z range over non-riser panel points, wall and panel counts. */
export function roomSummary(room: Room) {
const xs = room.walls.map(w => w.start[0]), ys = room.walls.map(w => w.start[1]);
const zs = room.ceiling.filter(p => !isRiser(p)).flatMap(p => p.points.map(q => q[2]));
return {
w: Math.max(...xs) - Math.min(...xs), d: Math.max(...ys) - Math.min(...ys),
ceilMin: zs.length ? Math.min(...zs) : 0, ceilMax: zs.length ? Math.max(...zs) : 0,
walls: room.walls.length, panels: room.ceiling.length,
};
}
// ---------- validation ----------
// Each validator returns errors formatted "<file> <json path>: <message>" and never throws, whatever JSON it gets.
const isObj = (v: unknown): v is Record<string, any> => typeof v === "object" && v !== null && !Array.isArray(v);
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
const isVec = (v: unknown, n: number): v is number[] => Array.isArray(v) && v.length === n && v.every(isNum);
const SURFACES = ["walls", "floor", "ceiling", "trim"];
/** Error collector for one file. Every check returns whether the value passed; an undefined value reads "missing". */
function checker(file: string) {
const errs: string[] = [];
const err = (path: string, msg: string): false => (errs.push(`${file}${path && " " + path}: ${msg}`), false);
const need = (ok: boolean, v: unknown, path: string, msg: string) => ok || err(path, v === undefined ? "missing" : msg);
const obj = (v: unknown, path: string): v is Record<string, any> => need(isObj(v), v, path, "must be an object");
const arr = (v: unknown, path: string, min = 0): v is any[] =>
Array.isArray(v) ? v.length >= min || err(path, `must have at least ${min} entries`) : need(false, v, path, "must be an array");
const str = (v: unknown, path: string, re?: RegExp) =>
need(typeof v === "string" && (!re || re.test(v)), v, path, re ? `must be a string matching ${re.source}` : "must be a string");
const num = (v: unknown, path: string, lo = -Infinity, hi = Infinity, gt = false): boolean =>
!isNum(v) ? need(false, v, path, "must be a number")
: ((gt ? v > lo : v >= lo) && v <= hi) || err(path, hi < Infinity ? `must be ${lo} to ${hi}` : `must be ${gt ? ">" : ">="} ${lo}`);
const vec = (v: unknown, path: string, n: 2 | 3): v is number[] => need(isVec(v, n), v, path, `must be an array of ${n} numbers`);
const oneOf = (v: unknown, path: string, opts: unknown[]) =>
need(opts.includes(v), v, path, `must be one of ${opts.map(o => JSON.stringify(o)).join(", ")}`);
const color = (v: unknown, path: string) => need(typeof v === "string" && /^#[0-9A-Fa-f]{6}$/.test(v), v, path, 'must be a "#RRGGBB" color');
const unique = (pairs: [unknown, string][]) => {
const seen = new Set<unknown>();
for (const [id, path] of pairs)
if (typeof id === "string") seen.has(id) ? err(path, `duplicate id "${id}"`) : seen.add(id);
};
const placements = (list: unknown, path: string) => {
if (!arr(list, path)) return;
list.forEach((p, i) => {
const at = `${path}[${i}]`;
if (!obj(p, at)) return;
str(p.id, `${at}.id`, ID_RE);
str(p.asset, `${at}.asset`, ID_RE);
num(p.x, `${at}.x`);
num(p.y, `${at}.y`);
if (p.elevation !== undefined) num(p.elevation, `${at}.elevation`, 0);
if (p.rotation !== undefined) num(p.rotation, `${at}.rotation`);
if (p.colors !== undefined && obj(p.colors, `${at}.colors`))
for (const [k, v] of Object.entries(p.colors)) color(v, `${at}.colors.${k}`);
});
unique(list.map((p, i): [unknown, string] => [p?.id, `${path}[${i}].id`]));
};
const materials = (m: unknown, path: string, keyOk: (k: string) => boolean, keys: string) => {
if (!obj(m, path)) return;
for (const [k, v] of Object.entries(m)) {
const at = `${path}.${k}`;
if (!keyOk(k)) err(at, `unknown key; use ${keys}`);
else if (obj(v, at)) {
color(v.color, `${at}.color`);
if (v.texture !== undefined) str(v.texture, `${at}.texture`);
}
}
};
return { errs, err, need, obj, arr, str, num, vec, oneOf, color, unique, placements, materials };
}
const overlaps = (a0: number, a1: number, b0: number, b1: number) => Math.min(a1, b1) - Math.max(a0, b0) > 1e-6;
export function validateRoom(room: unknown): string[] {
const { errs, err, need, obj, arr, str, num, vec, oneOf, unique, placements, materials } = checker("room.json");
if (!obj(room, "")) return errs;
arr(room.walls, "walls", 3);
const walls: any[] = Array.isArray(room.walls) ? room.walls : [];
const starts = walls.map(w => w?.start);
walls.forEach((w, i) => {
const at = `walls[${i}]`;
if (!obj(w, at)) return;
str(w.id, `${at}.id`, WALL_ID_RE);
vec(w.start, `${at}.start`, 2);
num(w.thickness, `${at}.thickness`, 0, Infinity, true);
if (arr(w.top, `${at}.top`, 1)) {
w.top.forEach((p: unknown, j: number) => {
const pt = `${at}.top[${j}]`, prev = w.top[j - 1];
if (!vec(p, pt, 2)) return;
if (p[1] < 0) err(pt, "h must be >= 0");
if (isVec(prev, 2) && p[0] < prev[0]) err(pt, "s must not decrease");
});
if (!w.top.some((p: unknown) => isVec(p, 2) && p[1] > 0)) err(`${at}.top`, "needs a point with h > 0");
}
const b = starts[(i + 1) % walls.length];
const len = isVec(w.start, 2) && isVec(b, 2) ? Math.hypot(b[0] - w.start[0], b[1] - w.start[1]) : NaN;
if (len < 1) err(at, `is ${fmtIn(len)} in long (min 1 in)`);
if (!arr(w.cutouts, `${at}.cutouts`)) return;
w.cutouts.forEach((c: unknown, j: number) => {
const ct = `${at}.cutouts[${j}]`;
if (!obj(c, ct)) return;
need(typeof c.id === "string" && c.id !== "", c.id, `${ct}.id`, "must be a non-empty string");
oneOf(c.kind, `${ct}.kind`, ["door", "window", "opening"]);
const sized = [num(c.offset, `${ct}.offset`, 0), num(c.width, `${ct}.width`, 0, Infinity, true),
num(c.sill, `${ct}.sill`, 0), num(c.height, `${ct}.height`, 0, Infinity, true)].every(Boolean);
if (c.kind === "door") {
oneOf(c.swing, `${ct}.swing`, ["in", "out", "none"]);
if (c.swing !== "none" || c.hinge !== undefined) oneOf(c.hinge, `${ct}.hinge`, ["left", "right", "both"]);
} else for (const k of ["swing", "hinge"]) if (c[k] !== undefined) err(`${ct}.${k}`, `only doors have a ${k}`);
if (!sized) return;
if (c.offset + c.width > len + 0.5) err(ct, `ends at s ${fmtIn(c.offset + c.width)}, past the wall's end (length ${fmtIn(len)})`);
for (const o of w.cutouts.slice(0, j))
if (isObj(o) && [o.offset, o.width, o.sill, o.height].every(isNum) &&
overlaps(c.offset, c.offset + c.width, o.offset, o.offset + o.width) && overlaps(c.sill, c.sill + c.height, o.sill, o.sill + o.height))
err(ct, `overlaps ${o.id}`);
});
});
if (walls.length >= 3 && starts.every(s => isVec(s, 2))) {
const poly = starts as Vec2[];
if (!isSimplePolygon(poly)) err("walls", "the floor polygon (the wall starts in order) crosses or touches itself");
const area = signedArea(poly);
if (area >= 0) err("walls", `must be listed clockwise seen from above (the floor polygon's signed area is ${fmtIn(area)}, must be < 0)`);
}
unique(walls.map((w, i): [unknown, string] => [w?.id, `walls[${i}].id`]));
unique(walls.flatMap((w, i) => (Array.isArray(w?.cutouts) ? w.cutouts : [])
.map((c: any, j: number): [unknown, string] => [c?.id, `walls[${i}].cutouts[${j}].id`])));
if (arr(room.ceiling, "ceiling")) {
room.ceiling.forEach((p, i) => {
const at = `ceiling[${i}]`;
if (!obj(p, at)) return;
need(typeof p.id === "string" && p.id !== "", p.id, `${at}.id`, "must be a non-empty string");
if (!arr(p.points, `${at}.points`, 3) || !p.points.map((q: unknown, j: number) => vec(q, `${at}.points[${j}]`, 3)).every(Boolean)) return;
const { n, d } = panelPlane(p.points);
if (!n.some(Boolean)) return err(`${at}.points`, "has no area");
const off = Math.max(...p.points.map((q: Vec3) => Math.abs(n[0] * q[0] + n[1] * q[1] + n[2] * q[2] - d)));
if (off > 0.5) err(`${at}.points`, `not planar: a point is ${fmtIn(off)} in off the panel's plane (max 0.5)`);
if (!isSimplePolygon(panelProjection(p.points, n))) err(`${at}.points`, "must be a simple polygon (edges cross or touch)");
});
unique(room.ceiling.map((p, i): [unknown, string] => [p?.id, `ceiling[${i}].id`]));
}
if (obj(room.trim, "trim")) {
num(room.trim.baseboard, "trim.baseboard", 0);
num(room.trim.casing, "trim.casing", 0);
}
placements(room.fixtures, "fixtures");
const wallIds = walls.map(w => w?.id).filter(id => typeof id === "string");
materials(room.materials, "materials", k => SURFACES.includes(k) || wallIds.includes(k),
`walls, floor, ceiling, trim or a wall id (${wallIds.join(", ")})`);
if (obj(room.overlay, "overlay")) {
const f = room.overlay.file;
need(f === null || (typeof f === "string" && /\.(glb|gltf|usdz|obj)$/i.test(f)), f, "overlay.file",
"must be null or a .glb, .gltf, .usdz or .obj path");
num(room.overlay.yaw, "overlay.yaw");
vec(room.overlay.offset, "overlay.offset", 3);
}
if (arr(room.notes, "notes")) room.notes.forEach((s, i) => str(s, `notes[${i}]`));
if (room.defaultLayout !== undefined) str(room.defaultLayout, "defaultLayout", ID_RE);
return errs;
}
export function validateLayout(layout: unknown, name: string): string[] {
const { errs, err, obj, arr, str, vec, num, unique, placements, materials } = checker(`layouts/${name}.json`);
if (!obj(layout, "")) return errs;
placements(layout.items, "items");
// Wall ids are checked against room.json in validateBundle.
materials(layout.materials, "materials", k => SURFACES.includes(k) || WALL_ID_RE.test(k), "walls, floor, ceiling, trim or a wall id");
if (obj(layout.views, "views"))
for (const [k, v] of Object.entries(layout.views)) {
const at = `views.${k}`;
if (!ID_RE.test(k)) err(at, `view name must match ${ID_RE.source}`);
if (!obj(v, at)) continue;
vec(v.position, `${at}.position`, 3);
vec(v.target, `${at}.target`, 3);
num(v.fov, `${at}.fov`, 10, 120);
}
if (layout.shop !== undefined && obj(layout.shop, "shop")) {
const shop = layout.shop, itemIds = Array.isArray(layout.items) ? layout.items.map((p: any) => p?.id) : [];
const slots: any[] = arr(shop.slots, "shop.slots") ? shop.slots : [], taken = new Set<string>();
slots.forEach((sl, i) => {
const at = `shop.slots[${i}]`;
if (!obj(sl, at)) return;
str(sl.id, `${at}.id`, ID_RE);
str(sl.label, `${at}.label`);
if (sl.decided !== undefined && typeof sl.decided !== "boolean") err(`${at}.decided`, "must be true or false");
if (arr(sl.items, `${at}.items`, 1)) sl.items.forEach((id: unknown, j: number) => {
if (!str(id, `${at}.items[${j}]`, ID_RE)) return;
if (!itemIds.includes(id)) err(`${at}.items[${j}]`, `no item "${id}" in items`);
else if (taken.has(id as string)) err(`${at}.items[${j}]`, `item "${id}" is in two slots`);
taken.add(id as string);
});
if (!arr(sl.options, `${at}.options`)) return;
sl.options.forEach((o: unknown, j: number) => {
const ot = `${at}.options[${j}]`;
if (!obj(o, ot)) return;
str(o.id, `${ot}.id`, ID_RE);
if (o.id === "concept") err(`${ot}.id`, `"concept" is reserved (the base items)`);
if (o.label !== undefined) str(o.label, `${ot}.label`);
placements(o.items, `${ot}.items`);
if (Array.isArray(o.items)) o.items.forEach((q: any, k: number) => {
if (itemIds.includes(q?.id) && !(Array.isArray(sl.items) && sl.items.includes(q.id)))
err(`${ot}.items[${k}].id`, `"${q.id}" is an item outside this slot`);
});
});
unique(sl.options.map((o: any, j: number): [unknown, string] => [o?.id, `${at}.options[${j}].id`]));
});
unique(slots.map((sl, i): [unknown, string] => [sl?.id, `shop.slots[${i}].id`]));
if (shop.presets !== undefined && obj(shop.presets, "shop.presets"))
for (const [name, picks] of Object.entries(shop.presets)) {
const at = `shop.presets.${name}`;
if (!ID_RE.test(name)) err(at, `preset name must match ${ID_RE.source}`);
if (!obj(picks, at)) continue;
for (const [slot, opt] of Object.entries(picks)) {
const sl = slots.find(x => x?.id === slot);
if (!sl) err(`${at}.${slot}`, "no such slot");
else if (opt !== "concept" && !(Array.isArray(sl.options) && sl.options.some((o: any) => o?.id === opt)))
err(`${at}.${slot}`, `no option "${opt}" in slot ${slot}`);
}
}
}
return errs;
}
export function validateAsset(asset: unknown, id: string): string[] {
const { errs, err, obj, str, num, vec, oneOf } = checker(`assets/${id}/asset.json`);
if (!obj(asset, "")) return errs;
str(asset.name, "name");
str(asset.source, "source");
oneOf(asset.file, "file", ["parts.json", "model.glb", "model.gltf"]);
if (vec(asset.dims, "dims", 3)) asset.dims.forEach((v, i) => num(v, `dims[${i}]`, 0, Infinity, true));
if (asset.modelYaw !== undefined) {
if (asset.file === "parts.json") err("modelYaw", "only applies to model.glb and model.gltf");
else oneOf(asset.modelYaw, "modelYaw", [0, 90, 180, 270]);
}
if (asset.notes !== undefined) str(asset.notes, "notes");
if (asset.product !== undefined && obj(asset.product, "product")) {
str(asset.product.retailer, "product.retailer");
str(asset.product.url, "product.url");
num(asset.product.price, "product.price", 0);
oneOf(asset.product.model, "product.model", ["retailer", "mock"]);
if (asset.product.pack !== undefined) num(asset.product.pack, "product.pack", 1);
}
return errs;
}
export function validateParts(parts: unknown, id: string): string[] {
const { errs, err, need, obj, arr, str, num, vec, oneOf, color } = checker(`assets/${id}/parts.json`);
if (!obj(parts, "")) return errs;
const mats: Record<string, unknown> = isObj(parts.materials) ? parts.materials : {};
if (obj(parts.materials, "materials") && !Object.keys(mats).length) err("materials", "needs at least one entry");
for (const [k, v] of Object.entries(mats)) color(v, `materials.${k}`);
if (!arr(parts.parts, "parts", 1)) return errs;
parts.parts.forEach((p, i) => {
const at = `parts[${i}]`;
if (!obj(p, at)) return;
if (p.name !== undefined) str(p.name, `${at}.name`);
vec(p.at, `${at}.at`, 3);
if (p.rotate !== undefined) vec(p.rotate, `${at}.rotate`, 3);
need(typeof p.material === "string" && Object.hasOwn(mats, p.material), p.material, `${at}.material`,
`must be one of the materials (${Object.keys(mats).join(", ")})`);
if (p.type === "box") {
if (vec(p.size, `${at}.size`, 3)) p.size.forEach((v, j) => num(v, `${at}.size[${j}]`, 0, Infinity, true));
if (p.radius !== undefined) num(p.radius, `${at}.radius`, 0);
} else if (p.type === "cylinder") {
num(p.radius, `${at}.radius`, 0, Infinity, true);
if (p.radiusTop !== undefined) num(p.radiusTop, `${at}.radiusTop`, 0);
num(p.height, `${at}.height`, 0, Infinity, true);
} else if (p.type === "lathe" || p.type === "extrude") {
const lathe = p.type === "lathe", path = `${at}.profile`;
if (arr(p.profile, path, lathe ? 2 : 3) && p.profile.map((q: unknown, j: number) => vec(q, `${path}[${j}]`, 2)).every(Boolean)) {
if (lathe) p.profile.forEach((q: Vec2, j: number) => q[0] < 0 && err(`${path}[${j}]`, "r must be >= 0"));
else if (!isSimplePolygon(p.profile)) err(path, "must be a simple polygon (edges cross or touch)");
}
if (!lathe) num(p.depth, `${at}.depth`, 0, Infinity, true);
} else oneOf(p.type, `${at}.type`, ["box", "cylinder", "lathe", "extrude"]);
});
return errs;
}
export function validateConfig(config: unknown): string[] {
const { errs, need, obj, num } = checker("config.json");
if (!obj(config, "")) return errs;
need(Number.isInteger(config.port) && config.port >= 1024 && config.port <= 65535, config.port, "port", "must be an integer 1024 to 65535");
num(config.eyeHeight, "eyeHeight", 36, 84);
return errs;
}
/** Cross-file rules only (run the per-file validators too). Tolerates malformed parts, which those report. */
export function validateBundle(b: Bundle): string[] {
const errs: string[] = [], lf = `layouts/${b.layoutName}.json`;
const rec = (v: unknown): Record<string, any> => (isObj(v) ? v : {});
const list = (v: unknown): any[] => (Array.isArray(v) ? v : []);
const room = rec(b.room), layout = rec(b.layout), assets = rec(b.assets), textures = rec(b.textures);
const wallIds = list(room.walls).map(w => w?.id);
const fixtureIds = list(room.fixtures).map(f => f?.id);
const shopItems = list(rec(layout.shop).slots).flatMap(sl => list(rec(sl).options).flatMap(o => list(rec(o).items)));
for (const [file, key, places] of [["room.json", "fixtures", room.fixtures], [lf, "items", layout.items], [lf, "shop items", shopItems]])
list(places).forEach((p, i) => {
if (!isObj(p)) return;
const at = `${file} ${key}[${i}]`, entry = Object.hasOwn(assets, p.asset) ? rec(assets[p.asset]) : undefined;
if (typeof p.asset === "string" && !entry) errs.push(`${at}.asset: unknown asset "${p.asset}"`); // non-strings: per-file error
else if (entry?.parts) { // colors on GLB/GLTF assets are a check DATA warning, not an error
const mats = rec(rec(entry.parts).materials);
for (const k of Object.keys(rec(p.colors)))
if (!Object.hasOwn(mats, k)) errs.push(`${at}.colors.${k}: no material "${k}" in assets/${p.asset}/parts.json (has: ${Object.keys(mats).join(", ")})`);
}
if (key === "items" && typeof p.id === "string" && fixtureIds.includes(p.id)) errs.push(`${at}.id: "${p.id}" is also a room fixture id`);
});
for (const [file, mats] of [["room.json", room.materials], [lf, layout.materials]])
for (const [k, m] of Object.entries(rec(mats))) {
const t = rec(m).texture;
if (typeof t === "string" && !Object.hasOwn(textures, t)) errs.push(`${file} materials.${k}.texture: no texture "${t}" in textures/index.json`);
}
for (const k of Object.keys(rec(layout.materials)))
if (WALL_ID_RE.test(k) && !wallIds.includes(k)) errs.push(`${lf} materials.${k}: no wall "${k}" (walls: ${wallIds.join(", ")})`);
return errs;
}
// ---------- shop picks ----------
/**
* Reads a pick spec: comma-separated tokens applied in order, each a preset name or `slot:option` ("slot:concept" puts the
* slot's base items back). Returns slot id -> option id.
*/
export function resolvePicks(shop: Shop | undefined, spec: string): { picks: Record<string, string>; errors: string[] } {
const picks: Record<string, string> = {}, errors: string[] = [];
for (const token of spec.split(",").map(t => t.trim()).filter(Boolean)) {
const [slot, opt] = token.split(":");
if (opt === undefined) {
const preset = shop?.presets?.[token];
if (!preset) errors.push(`no preset "${token}" (have: ${Object.keys(shop?.presets ?? {}).join(", ") || "none"})`);
else for (const [k, v] of Object.entries(preset)) v === "concept" ? delete picks[k] : (picks[k] = v);
continue;
}
const sl = shop?.slots.find(x => x.id === slot);
if (!sl) errors.push(`no slot "${slot}" (have: ${shop?.slots.map(x => x.id).join(", ") || "none"})`);
else if (opt === "concept") delete picks[slot];
else if (!sl.options.some(o => o.id === opt)) errors.push(`no option "${opt}" in slot ${slot} (have: concept, ${sl.options.map(o => o.id).join(", ")})`);
else picks[slot] = opt;
}
return { picks, errors };
}
/** The shortest spec for `picks`: a preset's name when they match one exactly, else `slot:option` pairs in slot order. */
export function pickSpec(shop: Shop, picks: Record<string, string>): string {
const same = (p: Record<string, string>) => {
const a = Object.entries(p).filter(([, v]) => v !== "concept");
return a.length === Object.keys(picks).length && a.every(([k, v]) => picks[k] === v);
};
const preset = Object.entries(shop.presets ?? {}).find(([, p]) => same(p));
return preset ? preset[0] : shop.slots.filter(sl => picks[sl.id]).map(sl => `${sl.id}:${picks[sl.id]}`).join(",");
}
/**
* The layout with each picked slot's base items replaced by the option's items. An item standing on another one in the
* base layout (a lamp on a nightstand: its bottom at the other's top, its center over the other's footprint) follows it
* when either changes: the same spot on the top, scaled to the new size and kept on it, at the new top height.
*/
export function applyPicks(b: Bundle, picks: Record<string, string>): Layout {
const { layout } = b, shop = layout.shop;
if (!shop || !Object.keys(picks).length) return layout;
const dims = (p: Placement) => b.assets[p.asset]?.asset.dims ?? [0, 0, 0];
const local = (p: Placement, x: number, y: number): Vec2 => { // plan point -> p's frame (+x right, +y back)
const r = ((p.rotation ?? 0) * Math.PI) / 180, dx = x - p.x, dy = y - p.y;
return [dx * Math.cos(r) + dy * Math.sin(r), -dx * Math.sin(r) + dy * Math.cos(r)];
};
const on = layout.items.flatMap(q => {
if (!q.elevation) return [];
const base = layout.items.find(a => a !== q && dims(a)[2] > FLAT_H && Math.abs((a.elevation ?? 0) + dims(a)[2] - q.elevation!) <= 0.6 &&
pointInPolygon([q.x, q.y], footprint(a, dims(a))));
if (!base) return [];
const [u, v] = local(base, q.x, q.y), [W, D] = dims(base);
return [{ id: q.id, under: base.id, u: u / W, v: v / D }];
});
const bySlot = new Map(shop.slots.flatMap(sl => (picks[sl.id] ? sl.items.map(id => [id, sl] as const) : [])));
const done = new Set<string>(), items: Placement[] = [];
for (const p of layout.items) {
const sl = bySlot.get(p.id);
if (!sl) items.push(p);
else if (!done.has(sl.id)) {
done.add(sl.id);
items.push(...sl.options.find(o => o.id === picks[sl.id])!.items);
}
}
const byId = new Map(items.map(p => [p.id, p])), was = new Map(layout.items.map(p => [p.id, p]));
for (const { id, under, u, v } of on) {
const q = byId.get(id), a = byId.get(under);
if (!q || !a || (q === was.get(id) && a === was.get(under))) continue;
const [W, D, H] = dims(a), e = Math.max(dims(q)[0], dims(q)[1]) / 2;
const clamp = (t: number, half: number) => Math.max(-Math.max(half - e, 0), Math.min(Math.max(half - e, 0), t));
const lx = clamp(u * W, W / 2), ly = clamp(v * D, D / 2), r = ((a.rotation ?? 0) * Math.PI) / 180;
byId.set(id, { ...q, x: a.x + lx * Math.cos(r) - ly * Math.sin(r), y: a.y + lx * Math.sin(r) + ly * Math.cos(r), elevation: (a.elevation ?? 0) + H });
}
return { ...layout, items: items.map(p => byId.get(p.id)!) };
}