weft_lang/model_lib.rs
1//! # weft-model — the Thread's modeling library
2//!
3//! The Weft-equivalent of a scene library, and the answer to "how does an
4//! agent make a *model*?": not by pasting vertices, and not by hand-placing
5//! primitives — by **calling functions that compute geometry**.
6//!
7//! ```text
8//! stairs(12, 0.18, 0.28, 1.2) twelve treads, computed by a fold
9//! column(5.2, 0.44) a turned profile, from two numbers
10//! at(ring_of(baluster, 12, 2.0), 0,1,0) twelve of a thing, around a circle
11//! model1("balustrade", …, marble()) …wearing a full PBR material
12//! ```
13//!
14//! Everything here returns plain Weft values — a flat list of carving steps
15//! (`Node`) and the materials its parts wear — which
16//! [`chisel`](../../chisel) meshes and bakes. That indirection is the point:
17//! the model is a **verified, content-addressed, deterministic program**, so
18//! it can be parameterised, cached by hash, exchanged between agents, and
19//! replayed byte-identically on any machine. Three.js gives an agent a scene
20//! API and hopes; this gives it a *proof*.
21//!
22//! **PBR is not an afterthought.** Every material constructor returns the
23//! full set — base color ramp, normal height, metallic and roughness bands,
24//! ambient occlusion — because a model that arrives without them is a model
25//! someone still has to finish.
26
27use std::collections::{BTreeMap, BTreeSet};
28
29use crate::pack::Package;
30use crate::{hash_def, Def, PrimOp, Term, Ty, WeftHash, FIX_SCALE, FIX_TAU};
31
32// ---------------------------------------------------------------------------
33// Term-building helpers (the generator's own comfort — not part of the ABI)
34// ---------------------------------------------------------------------------
35
36fn fx(v: f32) -> Term {
37 Term::Fix((v as f64 * FIX_SCALE as f64).round() as i64)
38}
39fn int(v: i64) -> Term {
40 Term::Int(v)
41}
42fn txt(s: &str) -> Term {
43 Term::Text(s.to_string())
44}
45fn var(i: u32) -> Term {
46 Term::Var(i)
47}
48fn p2(op: PrimOp, a: Term, b: Term) -> Term {
49 Term::Prim(op, vec![a, b])
50}
51fn p1(op: PrimOp, a: Term) -> Term {
52 Term::Prim(op, vec![a])
53}
54fn add(a: Term, b: Term) -> Term {
55 p2(PrimOp::FAdd, a, b)
56}
57fn sub(a: Term, b: Term) -> Term {
58 p2(PrimOp::FSub, a, b)
59}
60fn mul(a: Term, b: Term) -> Term {
61 p2(PrimOp::FMul, a, b)
62}
63fn div(a: Term, b: Term) -> Term {
64 p2(PrimOp::FDiv, a, b)
65}
66fn get(t: Term, field: &str) -> Term {
67 Term::Get(Box::new(t), field.to_string())
68}
69fn rec(fields: Vec<(&str, Term)>) -> Term {
70 Term::Rec(
71 fields
72 .into_iter()
73 .map(|(k, v)| (k.to_string(), v))
74 .collect(),
75 )
76}
77fn list(items: Vec<Term>) -> Term {
78 Term::ListNew(items)
79}
80fn rec_ty(fields: Vec<(&str, Ty)>) -> Ty {
81 Ty::Record(
82 fields
83 .into_iter()
84 .map(|(k, v)| (k.to_string(), v))
85 .collect(),
86 )
87}
88fn fix_list_ty() -> Ty {
89 Ty::List(Box::new(Ty::Fix))
90}
91/// A typed-but-empty `List(Fix)`: an empty `ListNew` has no element type, so
92/// a zero-cap map over a one-element list is how you say "none, of Fix".
93fn no_profile() -> Term {
94 Term::Map {
95 cap: 0,
96 list: Box::new(list(vec![fx(0.0)])),
97 body: Box::new(var(0)),
98 }
99}
100
101/// Repetition caps. Static, because the fuel bound must be: a model with
102/// more than this many steps is a scene, and scenes are the manifest's job.
103const CAP: u32 = 512;
104const REPEAT_CAP: u32 = 128;
105
106// ---------------------------------------------------------------------------
107// The ABI types (these must mirror `infinite_manifest::model`)
108// ---------------------------------------------------------------------------
109
110fn node_ty() -> Ty {
111 rec_ty(vec![
112 ("axis", Ty::Text),
113 ("d", Ty::Fix),
114 ("h", Ty::Fix),
115 ("k", Ty::Fix),
116 ("mode", Ty::Text),
117 ("part", Ty::Int),
118 ("prim", Ty::Text),
119 ("profile", fix_list_ty()),
120 ("r", Ty::Fix),
121 ("r2", Ty::Fix),
122 ("rot", Ty::Fix),
123 ("round", Ty::Fix),
124 ("w", Ty::Fix),
125 ("x", Ty::Fix),
126 ("y", Ty::Fix),
127 ("z", Ty::Fix),
128 ])
129}
130fn nodes_ty() -> Ty {
131 Ty::List(Box::new(node_ty()))
132}
133
134/// The bakeable recipe — the *inner* one, without a layer of its own.
135fn recipe_base_ty() -> Ty {
136 rec_ty(vec![
137 ("ao", Ty::Fix),
138 ("colors", Ty::List(Box::new(fix_list_ty()))),
139 ("height", Ty::Fix),
140 ("kind", Ty::Text),
141 ("metallic", fix_list_ty()),
142 ("octaves", Ty::Int),
143 ("roughness", fix_list_ty()),
144 ("scale", Ty::Fix),
145 ("seed", Ty::Int),
146 ("size", Ty::Int),
147 ("triplanar", Ty::Fix),
148 ])
149}
150/// The outer recipe: the same, plus an optional second layer blended over it
151/// (`mix: 0` means the layer is declared but unused, and costs nothing).
152fn recipe_ty() -> Ty {
153 let mut f = match recipe_base_ty() {
154 Ty::Record(m) => m,
155 _ => unreachable!(),
156 };
157 f.insert("over".into(), recipe_base_ty());
158 f.insert("mix".into(), Ty::Fix);
159 f.insert("mask_scale".into(), Ty::Fix);
160 f.insert("mask_seed".into(), Ty::Int);
161 Ty::Record(f)
162}
163fn material_ty() -> Ty {
164 rec_ty(vec![
165 ("color", fix_list_ty()),
166 ("emissive", Ty::Fix),
167 ("name", Ty::Text),
168 ("resolution", Ty::Int),
169 ("texture", recipe_ty()),
170 ("uv", Ty::Text),
171 ("uv_scale", Ty::Fix),
172 ])
173}
174fn model_ty() -> Ty {
175 rec_ty(vec![
176 ("materials", Ty::List(Box::new(material_ty()))),
177 ("name", Ty::Text),
178 ("nodes", nodes_ty()),
179 ])
180}
181
182/// Build one carving step, overriding the defaults that matter.
183fn node(prim: &str, over: Vec<(&str, Term)>) -> Term {
184 let mut f: BTreeMap<String, Term> = BTreeMap::new();
185 f.insert("prim".into(), txt(prim));
186 f.insert("mode".into(), txt("add"));
187 f.insert("part".into(), int(0));
188 f.insert("axis".into(), txt("y"));
189 for k in ["x", "y", "z", "rot", "r2", "round"] {
190 f.insert(k.into(), fx(0.0));
191 }
192 f.insert("r".into(), fx(0.5));
193 for k in ["h", "w", "d"] {
194 f.insert(k.into(), fx(1.0));
195 }
196 f.insert("k".into(), fx(0.25));
197 f.insert("profile".into(), no_profile());
198 for (k, v) in over {
199 f.insert(k.into(), v);
200 }
201 Term::Rec(f)
202}
203
204/// Copy the node bound at `elem` (a de Bruijn index), changing some fields —
205/// how every transform is written.
206fn copy_node(elem: u32, over: Vec<(&str, Term)>) -> Term {
207 let mut f: BTreeMap<String, Term> = BTreeMap::new();
208 for k in [
209 "axis", "d", "h", "k", "mode", "part", "prim", "profile", "r", "r2", "rot", "round", "w",
210 "x", "y", "z",
211 ] {
212 f.insert(k.into(), get(var(elem), k));
213 }
214 for (k, v) in over {
215 f.insert(k.into(), v);
216 }
217 Term::Rec(f)
218}
219
220fn def(params: Vec<Ty>, ret: Ty, body: Term) -> Def {
221 Def {
222 params,
223 ret,
224 effects: BTreeSet::new(),
225 body,
226 pre: None,
227 post: None,
228 }
229}
230
231/// A def under construction, so later defs can `Call` earlier ones by hash.
232struct Builder {
233 defs: Vec<Def>,
234 by_name: BTreeMap<String, (usize, WeftHash)>,
235}
236
237impl Builder {
238 fn new() -> Self {
239 Builder {
240 defs: Vec::new(),
241 by_name: BTreeMap::new(),
242 }
243 }
244 fn add(&mut self, name: &str, d: Def) -> WeftHash {
245 let h = hash_def(&d);
246 self.defs.push(d);
247 self.by_name
248 .insert(name.to_string(), (self.defs.len() - 1, h));
249 h
250 }
251 fn call(&self, name: &str, args: Vec<Term>) -> Term {
252 let (_, h) = self.by_name[name];
253 Term::Call(h, args)
254 }
255 fn idx(&self, name: &str) -> usize {
256 self.by_name[name].0
257 }
258}
259
260// ---------------------------------------------------------------------------
261// The library
262// ---------------------------------------------------------------------------
263
264/// Build the `weft-model` package: constructors, transforms, repeaters,
265/// parametric parts, and PBR materials.
266pub fn package() -> Package {
267 let mut b = Builder::new();
268
269 // --- primitives: each returns a one-step list, ready to compose --------
270 b.add(
271 "sphere",
272 def(
273 vec![Ty::Fix],
274 nodes_ty(),
275 list(vec![node("sphere", vec![("r", var(0))])]),
276 ),
277 );
278 b.add(
279 "cube",
280 def(
281 vec![Ty::Fix, Ty::Fix, Ty::Fix],
282 nodes_ty(),
283 list(vec![node(
284 "box",
285 vec![("w", var(2)), ("h", var(1)), ("d", var(0))],
286 )]),
287 ),
288 );
289 b.add(
290 "cylinder",
291 def(
292 vec![Ty::Fix, Ty::Fix],
293 nodes_ty(),
294 list(vec![node("cylinder", vec![("r", var(1)), ("h", var(0))])]),
295 ),
296 );
297 b.add(
298 "capsule",
299 def(
300 vec![Ty::Fix, Ty::Fix],
301 nodes_ty(),
302 list(vec![node("capsule", vec![("r", var(1)), ("h", var(0))])]),
303 ),
304 );
305 b.add(
306 "cone",
307 def(
308 vec![Ty::Fix, Ty::Fix, Ty::Fix],
309 nodes_ty(),
310 list(vec![node(
311 "cone",
312 vec![("r", var(2)), ("r2", var(1)), ("h", var(0))],
313 )]),
314 ),
315 );
316 b.add(
317 "torus",
318 def(
319 vec![Ty::Fix, Ty::Fix],
320 nodes_ty(),
321 list(vec![node("torus", vec![("r", var(1)), ("r2", var(0))])]),
322 ),
323 );
324 b.add(
325 "lathe",
326 def(
327 vec![fix_list_ty()],
328 nodes_ty(),
329 list(vec![node("lathe", vec![("profile", var(0))])]),
330 ),
331 );
332
333 // --- transforms: map over the steps ------------------------------------
334 // at(nodes, dx, dy, dz): inside the map body every outer var shifts by 1.
335 b.add(
336 "at",
337 def(
338 vec![nodes_ty(), Ty::Fix, Ty::Fix, Ty::Fix],
339 nodes_ty(),
340 Term::Map {
341 cap: CAP,
342 list: Box::new(var(3)),
343 body: Box::new(copy_node(
344 0,
345 vec![
346 ("x", add(get(var(0), "x"), var(3))),
347 ("y", add(get(var(0), "y"), var(2))),
348 ("z", add(get(var(0), "z"), var(1))),
349 ],
350 )),
351 },
352 ),
353 );
354 // spin(nodes, deg): turn the whole group about Y — positions AND the
355 // steps' own rotation, so a spun part stays itself.
356 // Scope inside the map body: [element=0, deg=1, nodes=2]; each `let`
357 // pushes one more binder, so the angle is re-indexed per depth. (Writing
358 // the scope out beats guessing: de Bruijn mistakes type-check as the
359 // wrong thing, not as an error.)
360 b.add(
361 "spin",
362 def(
363 vec![nodes_ty(), Ty::Fix],
364 nodes_ty(),
365 Term::Map {
366 cap: CAP,
367 list: Box::new(var(1)),
368 body: Box::new(Term::Let(
369 // c = cos θ, computed at [element=0, deg=1, nodes=2]
370 Box::new(p1(
371 PrimOp::FCos,
372 mul(div(var(1), fx(360.0)), Term::Fix(FIX_TAU)),
373 )),
374 Box::new(Term::Let(
375 // s = sin θ, computed at [c=0, element=1, deg=2, nodes=3]
376 Box::new(p1(
377 PrimOp::FSin,
378 mul(div(var(2), fx(360.0)), Term::Fix(FIX_TAU)),
379 )),
380 // body at [s=0, c=1, element=2, deg=3, nodes=4]
381 Box::new(copy_node(
382 2,
383 vec![
384 (
385 "x",
386 add(
387 mul(get(var(2), "x"), var(1)),
388 mul(get(var(2), "z"), var(0)),
389 ),
390 ),
391 (
392 "z",
393 sub(
394 mul(get(var(2), "z"), var(1)),
395 mul(get(var(2), "x"), var(0)),
396 ),
397 ),
398 ("rot", add(get(var(2), "rot"), var(3))),
399 ],
400 )),
401 )),
402 )),
403 },
404 ),
405 );
406 // mode(nodes, how, k): how these steps combine — "add" | "blend" | "cut"
407 // | "intersect". Carving is subtraction; this is the verb for it.
408 b.add(
409 "mode",
410 def(
411 vec![nodes_ty(), Ty::Text, Ty::Fix],
412 nodes_ty(),
413 Term::Map {
414 cap: CAP,
415 list: Box::new(var(2)),
416 body: Box::new(copy_node(0, vec![("mode", var(2)), ("k", var(1))])),
417 },
418 ),
419 );
420 b.add(
421 "cut",
422 def(
423 vec![nodes_ty()],
424 nodes_ty(),
425 b.call("mode", vec![var(0), txt("cut"), fx(0.25)]),
426 ),
427 );
428 b.add(
429 "blend",
430 def(
431 vec![nodes_ty(), Ty::Fix],
432 nodes_ty(),
433 b.call("mode", vec![var(1), txt("blend"), var(0)]),
434 ),
435 );
436 // part(nodes, i): which material group these steps belong to.
437 b.add(
438 "part",
439 def(
440 vec![nodes_ty(), Ty::Int],
441 nodes_ty(),
442 Term::Map {
443 cap: CAP,
444 list: Box::new(var(1)),
445 body: Box::new(copy_node(0, vec![("part", var(1))])),
446 },
447 ),
448 );
449 b.add(
450 "round",
451 def(
452 vec![nodes_ty(), Ty::Fix],
453 nodes_ty(),
454 Term::Map {
455 cap: CAP,
456 list: Box::new(var(1)),
457 body: Box::new(copy_node(0, vec![("round", var(1))])),
458 },
459 ),
460 );
461 b.add(
462 "lay",
463 def(
464 vec![nodes_ty(), Ty::Text],
465 nodes_ty(),
466 Term::Map {
467 cap: CAP,
468 list: Box::new(var(1)),
469 body: Box::new(copy_node(0, vec![("axis", var(1))])),
470 },
471 ),
472 );
473 // join(a, b): the composition primitive — this is what ListCat is for.
474 b.add(
475 "join",
476 def(
477 vec![nodes_ty(), nodes_ty()],
478 nodes_ty(),
479 p2(PrimOp::ListCat, var(1), var(0)),
480 ),
481 );
482
483 // --- repeaters: where code beats data ---------------------------------
484 // ring_of(nodes, n, radius): n copies around a circle, each turned to
485 // face out. A fold, because each round appends to the last.
486 b.add(
487 "ring_of",
488 def(
489 vec![nodes_ty(), Ty::Int, Ty::Fix],
490 nodes_ty(),
491 Term::Fold {
492 cap: REPEAT_CAP,
493 list: Box::new(Term::Iota {
494 cap: REPEAT_CAP,
495 count: Box::new(var(1)),
496 }),
497 init: Box::new(Term::Map {
498 cap: 0,
499 list: Box::new(var(2)),
500 body: Box::new(var(0)),
501 }),
502 // acc = Var 1, i = Var 0 → outer nodes = Var 4, n = Var 3, r = Var 2
503 body: Box::new(Term::Let(
504 // θ (degrees) = 360·i/n
505 Box::new(mul(
506 div(p1(PrimOp::FixOfInt, var(0)), p1(PrimOp::FixOfInt, var(3))),
507 fx(360.0),
508 )),
509 Box::new(p2(
510 PrimOp::ListCat,
511 var(2),
512 b.call(
513 "spin",
514 vec![
515 b.call(
516 "at",
517 vec![
518 var(5),
519 // x = r·sin θ, z = r·cos θ — the same
520 // azimuth convention the browser's
521 // architecture math uses.
522 mul(
523 var(3),
524 p1(
525 PrimOp::FSin,
526 mul(div(var(0), fx(360.0)), Term::Fix(FIX_TAU)),
527 ),
528 ),
529 fx(0.0),
530 mul(
531 var(3),
532 p1(
533 PrimOp::FCos,
534 mul(div(var(0), fx(360.0)), Term::Fix(FIX_TAU)),
535 ),
536 ),
537 ],
538 ),
539 var(0),
540 ],
541 ),
542 )),
543 )),
544 },
545 ),
546 );
547 // row_of(nodes, n, dx, dy, dz): n copies stepped along a vector.
548 b.add(
549 "row_of",
550 def(
551 vec![nodes_ty(), Ty::Int, Ty::Fix, Ty::Fix, Ty::Fix],
552 nodes_ty(),
553 Term::Fold {
554 cap: REPEAT_CAP,
555 list: Box::new(Term::Iota {
556 cap: REPEAT_CAP,
557 count: Box::new(var(3)),
558 }),
559 init: Box::new(Term::Map {
560 cap: 0,
561 list: Box::new(var(4)),
562 body: Box::new(var(0)),
563 }),
564 // acc = Var 1, i = Var 0 → nodes = Var 6, n = Var 5, dx..dz = 4,3,2
565 body: Box::new(p2(
566 PrimOp::ListCat,
567 var(1),
568 b.call(
569 "at",
570 vec![
571 var(6),
572 mul(p1(PrimOp::FixOfInt, var(0)), var(4)),
573 mul(p1(PrimOp::FixOfInt, var(0)), var(3)),
574 mul(p1(PrimOp::FixOfInt, var(0)), var(2)),
575 ],
576 ),
577 )),
578 },
579 ),
580 );
581
582 // --- parametric parts --------------------------------------------------
583 // column(h, r): plinth, torus base, entasis shaft, neck, flared capital —
584 // a classical profile, computed from two numbers.
585 b.add(
586 "column",
587 def(
588 vec![Ty::Fix, Ty::Fix],
589 nodes_ty(),
590 b.call(
591 "lathe",
592 vec![list(vec![
593 fx(0.0),
594 fx(0.0),
595 mul(var(0), fx(1.05)),
596 fx(0.0),
597 mul(var(0), fx(1.05)),
598 mul(var(1), fx(0.035)),
599 mul(var(0), fx(0.82)),
600 mul(var(1), fx(0.06)),
601 mul(var(0), fx(0.72)),
602 mul(var(1), fx(0.10)),
603 // entasis: the shaft swells slightly, then tapers
604 mul(var(0), fx(0.66)),
605 mul(var(1), fx(0.45)),
606 mul(var(0), fx(0.60)),
607 mul(var(1), fx(0.88)),
608 mul(var(0), fx(0.70)),
609 mul(var(1), fx(0.93)),
610 mul(var(0), fx(1.02)),
611 mul(var(1), fx(0.97)),
612 mul(var(0), fx(1.10)),
613 var(1),
614 fx(0.0),
615 var(1),
616 ])],
617 ),
618 ),
619 );
620 // stairs(n, rise, run, width): a fold — n treads, each stepped up and out.
621 b.add(
622 "stairs",
623 def(
624 vec![Ty::Int, Ty::Fix, Ty::Fix, Ty::Fix],
625 nodes_ty(),
626 Term::Fold {
627 cap: REPEAT_CAP,
628 list: Box::new(Term::Iota {
629 cap: REPEAT_CAP,
630 count: Box::new(var(3)),
631 }),
632 init: Box::new(Term::Map {
633 cap: 0,
634 list: Box::new(b.call("cube", vec![fx(1.0), fx(1.0), fx(1.0)])),
635 body: Box::new(var(0)),
636 }),
637 // acc = Var 1, i = Var 0 → n = Var 5, rise = 4, run = 3, width = 2
638 // Each step is SOLID — a block from the ground to its tread,
639 // not a floating slab. That is what a staircase looks like,
640 // and it costs the same twelve triangles.
641 body: Box::new(p2(
642 PrimOp::ListCat,
643 var(1),
644 b.call(
645 "at",
646 vec![
647 b.call(
648 "cube",
649 vec![
650 var(2),
651 mul(add(p1(PrimOp::FixOfInt, var(0)), fx(1.0)), var(4)),
652 var(3),
653 ],
654 ),
655 fx(0.0),
656 // centre of a block rising from 0 to (i+1)·rise
657 mul(
658 mul(add(p1(PrimOp::FixOfInt, var(0)), fx(1.0)), var(4)),
659 fx(0.5),
660 ),
661 mul(add(p1(PrimOp::FixOfInt, var(0)), fx(0.5)), var(3)),
662 ],
663 ),
664 )),
665 },
666 ),
667 );
668 // arch(w, h, thickness): a slab with a round-topped opening cut out.
669 b.add(
670 "arch",
671 def(
672 vec![Ty::Fix, Ty::Fix, Ty::Fix],
673 nodes_ty(),
674 b.call(
675 "join",
676 vec![
677 b.call(
678 "at",
679 vec![
680 b.call("cube", vec![var(2), var(1), var(0)]),
681 fx(0.0),
682 div(var(1), fx(2.0)),
683 fx(0.0),
684 ],
685 ),
686 b.call(
687 "cut",
688 vec![b.call(
689 "join",
690 vec![
691 // the round head
692 b.call(
693 "at",
694 vec![
695 b.call(
696 "lay",
697 vec![
698 b.call(
699 "cylinder",
700 vec![
701 mul(var(2), fx(0.33)),
702 mul(var(0), fx(2.0)),
703 ],
704 ),
705 txt("z"),
706 ],
707 ),
708 fx(0.0),
709 mul(var(1), fx(0.62)),
710 fx(0.0),
711 ],
712 ),
713 // the doorway below it
714 b.call(
715 "at",
716 vec![
717 b.call(
718 "cube",
719 vec![
720 mul(var(2), fx(0.66)),
721 mul(var(1), fx(0.62)),
722 mul(var(0), fx(2.0)),
723 ],
724 ),
725 fx(0.0),
726 mul(var(1), fx(0.31)),
727 fx(0.0),
728 ],
729 ),
730 ],
731 )],
732 ),
733 ],
734 ),
735 ),
736 );
737 // vase(h, belly, neck): a turned vessel — the classic lathe demo, and a
738 // fair test of whether a language can *describe* a curve.
739 b.add(
740 "vase",
741 def(
742 vec![Ty::Fix, Ty::Fix, Ty::Fix],
743 nodes_ty(),
744 b.call(
745 "lathe",
746 vec![list(vec![
747 fx(0.0),
748 fx(0.0),
749 mul(var(1), fx(0.55)),
750 fx(0.0),
751 mul(var(1), fx(0.62)),
752 mul(var(2), fx(0.06)),
753 mul(var(1), fx(0.80)),
754 mul(var(2), fx(0.22)),
755 var(1),
756 mul(var(2), fx(0.45)),
757 mul(var(1), fx(0.86)),
758 mul(var(2), fx(0.68)),
759 mul(var(0), fx(1.05)),
760 mul(var(2), fx(0.86)),
761 var(0),
762 mul(var(2), fx(0.95)),
763 mul(var(0), fx(1.25)),
764 var(2),
765 fx(0.0),
766 var(2),
767 ])],
768 ),
769 ),
770 );
771 // bowl(r, wall): a sphere, flattened and hollowed.
772 b.add(
773 "bowl",
774 def(
775 vec![Ty::Fix, Ty::Fix],
776 nodes_ty(),
777 b.call(
778 "join",
779 vec![
780 b.call(
781 "join",
782 vec![
783 b.call(
784 "at",
785 vec![b.call("sphere", vec![var(1)]), fx(0.0), var(1), fx(0.0)],
786 ),
787 b.call(
788 "cut",
789 vec![b.call(
790 "at",
791 vec![
792 b.call("sphere", vec![sub(var(1), var(0))]),
793 fx(0.0),
794 add(var(1), mul(var(0), fx(0.9))),
795 fx(0.0),
796 ],
797 )],
798 ),
799 ],
800 ),
801 // flat foot: cut everything below the base
802 b.call(
803 "cut",
804 vec![b.call(
805 "at",
806 vec![
807 b.call(
808 "cube",
809 vec![
810 mul(var(1), fx(4.0)),
811 mul(var(1), fx(2.0)),
812 mul(var(1), fx(4.0)),
813 ],
814 ),
815 fx(0.0),
816 mul(var(1), fx(-0.85)),
817 fx(0.0),
818 ],
819 )],
820 ),
821 ],
822 ),
823 ),
824 );
825 // table(w, d, h): a top and four legs — the "does composition work?" part.
826 b.add(
827 "table",
828 def(
829 vec![Ty::Fix, Ty::Fix, Ty::Fix],
830 nodes_ty(),
831 b.call(
832 "join",
833 vec![
834 b.call(
835 "round",
836 vec![
837 b.call(
838 "at",
839 vec![
840 b.call("cube", vec![var(2), mul(var(0), fx(0.08)), var(1)]),
841 fx(0.0),
842 var(0),
843 fx(0.0),
844 ],
845 ),
846 fx(0.02),
847 ],
848 ),
849 b.call(
850 "row_of",
851 vec![
852 b.call(
853 "row_of",
854 vec![
855 b.call(
856 "at",
857 vec![
858 b.call(
859 "cube",
860 vec![
861 mul(var(2), fx(0.07)),
862 mul(var(0), fx(0.96)),
863 mul(var(2), fx(0.07)),
864 ],
865 ),
866 mul(var(2), fx(-0.42)),
867 mul(var(0), fx(0.48)),
868 mul(var(1), fx(-0.40)),
869 ],
870 ),
871 int(2),
872 mul(var(2), fx(0.84)),
873 fx(0.0),
874 fx(0.0),
875 ],
876 ),
877 int(2),
878 fx(0.0),
879 fx(0.0),
880 mul(var(1), fx(0.80)),
881 ],
882 ),
883 ],
884 ),
885 ),
886 );
887 // rock(r, seed): three spheres melted together and flattened — organic
888 // form from a deterministic "random" (sin is a hash you can verify).
889 let jitter = |seed: Term, salt: f32, amp: f32| {
890 mul(
891 p1(PrimOp::FSin, add(mul(seed, fx(12.9898)), fx(salt))),
892 fx(amp),
893 )
894 };
895 b.add(
896 "rock",
897 def(
898 vec![Ty::Fix, Ty::Fix],
899 nodes_ty(),
900 b.call(
901 "join",
902 vec![
903 b.call(
904 "blend",
905 vec![
906 b.call(
907 "join",
908 vec![
909 b.call(
910 "at",
911 vec![
912 b.call("sphere", vec![var(1)]),
913 fx(0.0),
914 mul(var(1), fx(0.8)),
915 fx(0.0),
916 ],
917 ),
918 b.call(
919 "join",
920 vec![
921 b.call(
922 "at",
923 vec![
924 b.call("sphere", vec![mul(var(1), fx(0.72))]),
925 mul(var(1), jitter(var(0), 1.7, 0.6)),
926 mul(var(1), fx(0.62)),
927 mul(var(1), jitter(var(0), 4.3, 0.6)),
928 ],
929 ),
930 b.call(
931 "at",
932 vec![
933 b.call("sphere", vec![mul(var(1), fx(0.58))]),
934 mul(var(1), jitter(var(0), 8.1, -0.7)),
935 mul(var(1), fx(0.52)),
936 mul(var(1), jitter(var(0), 2.9, -0.5)),
937 ],
938 ),
939 ],
940 ),
941 ],
942 ),
943 mul(var(1), fx(0.42)),
944 ],
945 ),
946 // sit it flat on the ground
947 b.call(
948 "cut",
949 vec![b.call(
950 "at",
951 vec![
952 b.call(
953 "cube",
954 vec![
955 mul(var(1), fx(6.0)),
956 mul(var(1), fx(2.0)),
957 mul(var(1), fx(6.0)),
958 ],
959 ),
960 fx(0.0),
961 mul(var(1), fx(-1.0)),
962 fx(0.0),
963 ],
964 )],
965 ),
966 ],
967 ),
968 ),
969 );
970
971 // --- materials: the full PBR set, tuned, by name -----------------------
972 let recipe = |kind: &str,
973 scale: f32,
974 octaves: i64,
975 seed: i64,
976 colors: Vec<[f32; 3]>,
977 rough: [f32; 2],
978 metal: [f32; 2],
979 height: f32,
980 ao: f32,
981 triplanar: f32| {
982 rec(vec![
983 ("kind", txt(kind)),
984 ("scale", fx(scale)),
985 ("octaves", int(octaves)),
986 ("seed", int(seed)),
987 (
988 "colors",
989 list(
990 colors
991 .into_iter()
992 .map(|c| list(vec![fx(c[0]), fx(c[1]), fx(c[2])]))
993 .collect(),
994 ),
995 ),
996 ("roughness", list(vec![fx(rough[0]), fx(rough[1])])),
997 ("metallic", list(vec![fx(metal[0]), fx(metal[1])])),
998 ("height", fx(height)),
999 ("ao", fx(ao)),
1000 ("size", int(256)),
1001 ("triplanar", fx(triplanar)),
1002 ])
1003 };
1004 // A material wraps a recipe with the render-side knobs. `over` is a
1005 // declared-but-unused layer (mix 0) that `weathered` can switch on.
1006 let material = |name: &str, texture: Term, uv: &str| {
1007 let base = texture.clone();
1008 rec(vec![
1009 ("name", txt(name)),
1010 ("color", list(vec![fx(1.0), fx(1.0), fx(1.0), fx(1.0)])),
1011 ("emissive", fx(0.0)),
1012 ("resolution", int(48)),
1013 ("uv", txt(uv)),
1014 ("uv_scale", fx(0.5)),
1015 ("texture", {
1016 let mut f: BTreeMap<String, Term> = match base {
1017 Term::Rec(m) => m,
1018 _ => unreachable!(),
1019 };
1020 f.insert(
1021 "over".into(),
1022 match texture {
1023 Term::Rec(m) => Term::Rec(m),
1024 _ => unreachable!(),
1025 },
1026 );
1027 f.insert("mix".into(), fx(0.0));
1028 f.insert("mask_scale".into(), fx(3.0));
1029 f.insert("mask_seed".into(), int(0));
1030 Term::Rec(f)
1031 }),
1032 ])
1033 };
1034 let moss_recipe = recipe(
1035 "voronoi",
1036 9.0,
1037 4,
1038 21,
1039 vec![[0.16, 0.28, 0.13], [0.30, 0.45, 0.20], [0.22, 0.36, 0.16]],
1040 [1.0, 0.9],
1041 [0.0, 0.0],
1042 0.5,
1043 0.4,
1044 0.5,
1045 );
1046 for (name, r, uv) in [
1047 (
1048 "marble",
1049 recipe(
1050 "veins",
1051 3.0,
1052 5,
1053 5,
1054 vec![[0.42, 0.43, 0.47], [0.86, 0.85, 0.83], [0.97, 0.96, 0.94]],
1055 [0.34, 0.20],
1056 [0.0, 0.0],
1057 0.10,
1058 0.20,
1059 0.5,
1060 ),
1061 "auto",
1062 ),
1063 (
1064 "granite",
1065 recipe(
1066 "voronoi",
1067 7.0,
1068 4,
1069 11,
1070 vec![[0.40, 0.40, 0.43], [0.56, 0.54, 0.52], [0.33, 0.33, 0.38]],
1071 [0.95, 0.78],
1072 [0.0, 0.05],
1073 0.40,
1074 0.45,
1075 0.5,
1076 ),
1077 "auto",
1078 ),
1079 (
1080 "sandstone",
1081 recipe(
1082 "fbm",
1083 5.0,
1084 5,
1085 3,
1086 vec![[0.72, 0.62, 0.45], [0.87, 0.79, 0.62]],
1087 [0.94, 0.82],
1088 [0.0, 0.0],
1089 0.35,
1090 0.35,
1091 0.5,
1092 ),
1093 "auto",
1094 ),
1095 (
1096 "wood",
1097 recipe(
1098 "wood",
1099 4.0,
1100 4,
1101 7,
1102 vec![[0.32, 0.20, 0.11], [0.55, 0.36, 0.20], [0.44, 0.28, 0.15]],
1103 [0.78, 0.55],
1104 [0.0, 0.0],
1105 0.30,
1106 0.25,
1107 0.0,
1108 ),
1109 "box",
1110 ),
1111 (
1112 "iron",
1113 recipe(
1114 "fbm",
1115 7.0,
1116 4,
1117 9,
1118 vec![[0.18, 0.18, 0.20], [0.34, 0.33, 0.35]],
1119 [0.55, 0.32],
1120 [0.9, 1.0],
1121 0.18,
1122 0.25,
1123 0.0,
1124 ),
1125 "auto",
1126 ),
1127 (
1128 "brass",
1129 recipe(
1130 "fbm",
1131 6.0,
1132 3,
1133 13,
1134 vec![[0.52, 0.40, 0.15], [0.85, 0.70, 0.32]],
1135 [0.42, 0.22],
1136 [1.0, 1.0],
1137 0.12,
1138 0.20,
1139 0.0,
1140 ),
1141 "auto",
1142 ),
1143 (
1144 "terracotta",
1145 recipe(
1146 "fbm",
1147 5.0,
1148 5,
1149 4,
1150 vec![[0.48, 0.26, 0.16], [0.70, 0.44, 0.27]],
1151 [0.80, 0.58],
1152 [0.0, 0.0],
1153 0.22,
1154 0.22,
1155 0.0,
1156 ),
1157 "auto",
1158 ),
1159 (
1160 "plaster",
1161 recipe(
1162 "fbm",
1163 3.0,
1164 4,
1165 17,
1166 vec![[0.80, 0.78, 0.74], [0.92, 0.90, 0.86]],
1167 [0.92, 0.85],
1168 [0.0, 0.0],
1169 0.10,
1170 0.18,
1171 0.5,
1172 ),
1173 "auto",
1174 ),
1175 ("moss", moss_recipe.clone(), "auto"),
1176 ] {
1177 b.add(name, def(vec![], material_ty(), material(name, r, uv)));
1178 }
1179 // tint(material, r, g, b): the same material, another colour.
1180 b.add(
1181 "tint",
1182 def(
1183 vec![material_ty(), Ty::Fix, Ty::Fix, Ty::Fix],
1184 material_ty(),
1185 rec(vec![
1186 ("name", get(var(3), "name")),
1187 ("color", list(vec![var(2), var(1), var(0), fx(1.0)])),
1188 ("emissive", get(var(3), "emissive")),
1189 ("resolution", get(var(3), "resolution")),
1190 ("uv", get(var(3), "uv")),
1191 ("uv_scale", get(var(3), "uv_scale")),
1192 ("texture", get(var(3), "texture")),
1193 ]),
1194 ),
1195 );
1196 // glow(material, strength): the same material, lit from within.
1197 b.add(
1198 "glow",
1199 def(
1200 vec![material_ty(), Ty::Fix],
1201 material_ty(),
1202 rec(vec![
1203 ("name", get(var(1), "name")),
1204 ("color", get(var(1), "color")),
1205 ("emissive", var(0)),
1206 ("resolution", get(var(1), "resolution")),
1207 ("uv", get(var(1), "uv")),
1208 ("uv_scale", get(var(1), "uv_scale")),
1209 ("texture", get(var(1), "texture")),
1210 ]),
1211 ),
1212 );
1213 // weathered(material, mix): moss creeping over it — layering, switched on.
1214 b.add(
1215 "weathered",
1216 def(
1217 vec![material_ty(), Ty::Fix],
1218 material_ty(),
1219 rec(vec![
1220 ("name", get(var(1), "name")),
1221 ("color", get(var(1), "color")),
1222 ("emissive", get(var(1), "emissive")),
1223 ("resolution", get(var(1), "resolution")),
1224 ("uv", get(var(1), "uv")),
1225 ("uv_scale", get(var(1), "uv_scale")),
1226 ("texture", {
1227 let tex = get(var(1), "texture");
1228 let mut f: BTreeMap<String, Term> = BTreeMap::new();
1229 for k in [
1230 "ao",
1231 "colors",
1232 "height",
1233 "kind",
1234 "metallic",
1235 "octaves",
1236 "roughness",
1237 "scale",
1238 "seed",
1239 "size",
1240 "triplanar",
1241 ] {
1242 f.insert(k.into(), get(tex.clone(), k));
1243 }
1244 f.insert("over".into(), moss_recipe.clone());
1245 f.insert("mix".into(), var(0));
1246 f.insert("mask_scale".into(), fx(2.5));
1247 f.insert("mask_seed".into(), int(19));
1248 Term::Rec(f)
1249 }),
1250 ]),
1251 ),
1252 );
1253
1254 // --- assembly ----------------------------------------------------------
1255 b.add(
1256 "model",
1257 def(
1258 vec![Ty::Text, nodes_ty(), Ty::List(Box::new(material_ty()))],
1259 model_ty(),
1260 rec(vec![
1261 ("name", var(2)),
1262 ("nodes", var(1)),
1263 ("materials", var(0)),
1264 ]),
1265 ),
1266 );
1267 b.add(
1268 "model1",
1269 def(
1270 vec![Ty::Text, nodes_ty(), material_ty()],
1271 model_ty(),
1272 rec(vec![
1273 ("name", var(2)),
1274 ("nodes", var(1)),
1275 ("materials", list(vec![var(0)])),
1276 ]),
1277 ),
1278 );
1279
1280 // --- the catalog: what this package can MAKE, and how to ask ----------
1281 //
1282 // A layout that cannot find a part in the store needs to commission one,
1283 // and to commission it must know which export makes a `column` and what
1284 // to pass it. Declaring that here turns any published package into a
1285 // *supplier*: the store stops being a pile of files and becomes a market
1286 // of parts and the programs that make them.
1287 //
1288 // `args` is a tiny expression per parameter, evaluated by the binder
1289 // against the need's own dimensions: `h`, `w`, `d` (metres), a literal
1290 // number, or either scaled — `w*0.5`. `material` names a palette slot
1291 // (`stone`, `wood`, `metal`) so a commission inherits the place's style.
1292 let catalog_entry_ty = rec_ty(vec![
1293 ("args", Ty::List(Box::new(Ty::Text))),
1294 ("export", Ty::Text),
1295 ("kind", Ty::Text),
1296 ("material", Ty::Text),
1297 ]);
1298 let entry = |kind: &str, export: &str, args: Vec<&str>, material: &str| {
1299 rec(vec![
1300 ("kind", txt(kind)),
1301 ("export", txt(export)),
1302 ("args", list(args.into_iter().map(txt).collect())),
1303 ("material", txt(material)),
1304 ])
1305 };
1306 b.add(
1307 "catalog",
1308 def(
1309 vec![],
1310 Ty::List(Box::new(catalog_entry_ty)),
1311 list(vec![
1312 entry("column", "column", vec!["h", "w*0.5"], "stone"),
1313 entry("arch", "arch", vec!["w", "h", "d"], "stone"),
1314 entry(
1315 "stairs",
1316 "stairs",
1317 vec!["12", "h*0.083", "d*0.083", "w"],
1318 "stone",
1319 ),
1320 entry("table", "table", vec!["w", "d", "h"], "wood"),
1321 entry("vessel", "vase", vec!["h", "w*0.34", "w*0.13"], "stone"),
1322 entry("bowl", "bowl", vec!["w*0.5", "w*0.08"], "stone"),
1323 entry("rock", "rock", vec!["w*0.5", "3"], "stone"),
1324 entry("boulder", "rock", vec!["w*0.5", "7"], "stone"),
1325 ]),
1326 ),
1327 );
1328
1329 // --- a showpiece: everything at once, in one call ----------------------
1330 // amphora(h): a two-material vessel — terracotta body, brass collar.
1331 b.add(
1332 "amphora",
1333 def(
1334 vec![Ty::Fix],
1335 model_ty(),
1336 b.call(
1337 "model",
1338 vec![
1339 txt("amphora"),
1340 b.call(
1341 "join",
1342 vec![
1343 // vase(height, belly radius, neck radius)
1344 b.call(
1345 "vase",
1346 vec![var(0), mul(var(0), fx(0.34)), mul(var(0), fx(0.13))],
1347 ),
1348 b.call(
1349 "part",
1350 vec![
1351 b.call(
1352 "at",
1353 vec![
1354 b.call(
1355 "torus",
1356 vec![mul(var(0), fx(0.17)), mul(var(0), fx(0.028))],
1357 ),
1358 fx(0.0),
1359 mul(var(0), fx(0.88)),
1360 fx(0.0),
1361 ],
1362 ),
1363 int(1),
1364 ],
1365 ),
1366 ],
1367 ),
1368 list(vec![b.call("terracotta", vec![]), b.call("brass", vec![])]),
1369 ],
1370 ),
1371 ),
1372 );
1373
1374 let names: Vec<String> = b.by_name.keys().cloned().collect();
1375 let exports: Vec<(&str, usize)> = names.iter().map(|n| (n.as_str(), b.idx(n))).collect();
1376 Package::build("weft-model", b.defs.clone(), exports).expect("library builds")
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381 use super::*;
1382 use crate::{eval_call, pack::link, Value};
1383
1384 fn call_export(pkg: &Package, name: &str, args: Vec<Value>) -> Value {
1385 let hash = pkg
1386 .export(name)
1387 .unwrap_or_else(|| panic!("export '{name}'"));
1388 let entry = pkg.defs.get(&hash).cloned().expect("def");
1389 let module = link(&[pkg.clone()], vec![entry], 0).expect("links");
1390 eval_call(&module, module.entry, args, 40_000_000)
1391 .unwrap_or_else(|e| panic!("'{name}' failed: {e:?}"))
1392 .value
1393 }
1394
1395 fn nodes_len(v: &Value) -> usize {
1396 match v {
1397 Value::List(xs) => xs.len(),
1398 other => panic!("expected a node list, got {other:?}"),
1399 }
1400 }
1401
1402 #[test]
1403 fn the_library_verifies_as_a_package() {
1404 let pkg = package();
1405 pkg.verify()
1406 .expect("every def type-checks, terminates, and hashes");
1407 assert!(
1408 pkg.exports.len() > 25,
1409 "a library worth the name: {}",
1410 pkg.exports.len()
1411 );
1412 for must in [
1413 "sphere", "at", "join", "stairs", "column", "marble", "model1", "amphora",
1414 ] {
1415 assert!(pkg.exports.contains_key(must), "exports {must}");
1416 }
1417 }
1418
1419 #[test]
1420 fn primitives_and_transforms_compose() {
1421 let pkg = package();
1422 let one = call_export(&pkg, "sphere", vec![Value::Fix(FIX_SCALE)]);
1423 assert_eq!(nodes_len(&one), 1);
1424
1425 // join concatenates; at moves; part regroups.
1426 let joined = call_export(
1427 &pkg,
1428 "join",
1429 vec![
1430 call_export(&pkg, "sphere", vec![Value::Fix(FIX_SCALE)]),
1431 call_export(&pkg, "cube", vec![Value::Fix(FIX_SCALE); 3]),
1432 ],
1433 );
1434 assert_eq!(nodes_len(&joined), 2);
1435
1436 let moved = call_export(
1437 &pkg,
1438 "at",
1439 vec![
1440 call_export(&pkg, "sphere", vec![Value::Fix(FIX_SCALE)]),
1441 Value::Fix(2 * FIX_SCALE),
1442 Value::Fix(3 * FIX_SCALE),
1443 Value::Fix(4 * FIX_SCALE),
1444 ],
1445 );
1446 let Value::List(items) = &moved else { panic!() };
1447 let Value::Rec(f) = &items[0] else { panic!() };
1448 assert_eq!(f["x"], Value::Fix(2 * FIX_SCALE));
1449 assert_eq!(f["y"], Value::Fix(3 * FIX_SCALE));
1450 assert_eq!(f["z"], Value::Fix(4 * FIX_SCALE));
1451
1452 // spin turns positions about Y: +x at 90° becomes… −z (the browser's
1453 // azimuth convention), and the step keeps its own rotation.
1454 let spun = call_export(
1455 &pkg,
1456 "spin",
1457 vec![
1458 call_export(
1459 &pkg,
1460 "at",
1461 vec![
1462 call_export(&pkg, "sphere", vec![Value::Fix(FIX_SCALE)]),
1463 Value::Fix(FIX_SCALE),
1464 Value::Fix(0),
1465 Value::Fix(0),
1466 ],
1467 ),
1468 Value::Fix(90 * FIX_SCALE),
1469 ],
1470 );
1471 let Value::List(items) = &spun else { panic!() };
1472 let Value::Rec(f) = &items[0] else { panic!() };
1473 let (x, z) = (
1474 match f["x"] {
1475 Value::Fix(v) => v as f64 / FIX_SCALE as f64,
1476 _ => panic!(),
1477 },
1478 match f["z"] {
1479 Value::Fix(v) => v as f64 / FIX_SCALE as f64,
1480 _ => panic!(),
1481 },
1482 );
1483 assert!(x.abs() < 0.02, "x ≈ 0 after a quarter turn, got {x}");
1484 assert!(
1485 (z + 1.0).abs() < 0.02,
1486 "z ≈ −1 after a quarter turn, got {z}"
1487 );
1488 }
1489
1490 #[test]
1491 fn loops_are_what_make_it_a_language() {
1492 let pkg = package();
1493 // Twelve treads from one fold — the thing data can't do.
1494 let steps = call_export(
1495 &pkg,
1496 "stairs",
1497 vec![
1498 Value::Int(12),
1499 Value::Fix(180_000),
1500 Value::Fix(280_000),
1501 Value::Fix(1_200_000),
1502 ],
1503 );
1504 assert_eq!(nodes_len(&steps), 12);
1505
1506 // A ring of eight, on the circle it was given.
1507 let ring = call_export(
1508 &pkg,
1509 "ring_of",
1510 vec![
1511 call_export(&pkg, "sphere", vec![Value::Fix(FIX_SCALE / 4)]),
1512 Value::Int(8),
1513 Value::Fix(3 * FIX_SCALE),
1514 ],
1515 );
1516 assert_eq!(nodes_len(&ring), 8);
1517 let Value::List(items) = &ring else { panic!() };
1518 for it in items {
1519 let Value::Rec(f) = it else { panic!() };
1520 let g = |k: &str| match f[k] {
1521 Value::Fix(v) => v as f64 / FIX_SCALE as f64,
1522 _ => panic!(),
1523 };
1524 let r = (g("x") * g("x") + g("z") * g("z")).sqrt();
1525 assert!((r - 3.0).abs() < 0.05, "on the circle: {r}");
1526 }
1527
1528 // row_of steps along its vector.
1529 let row = call_export(
1530 &pkg,
1531 "row_of",
1532 vec![
1533 call_export(&pkg, "sphere", vec![Value::Fix(FIX_SCALE / 4)]),
1534 Value::Int(5),
1535 Value::Fix(FIX_SCALE),
1536 Value::Fix(0),
1537 Value::Fix(0),
1538 ],
1539 );
1540 assert_eq!(nodes_len(&row), 5);
1541 }
1542
1543 #[test]
1544 fn every_part_and_material_evaluates() {
1545 let pkg = package();
1546 let f = |v: f32| Value::Fix((v * FIX_SCALE as f32) as i64);
1547 for (name, args) in [
1548 ("column", vec![f(5.2), f(0.44)]),
1549 ("vase", vec![f(0.42), f(0.34), f(1.2)]),
1550 ("bowl", vec![f(0.5), f(0.06)]),
1551 ("arch", vec![f(3.0), f(4.0), f(0.6)]),
1552 ("table", vec![f(1.6), f(0.9), f(0.75)]),
1553 ("rock", vec![f(0.8), f(3.0)]),
1554 ] {
1555 let v = call_export(&pkg, name, args);
1556 assert!(nodes_len(&v) >= 1, "{name} carved nothing");
1557 }
1558 for mat in [
1559 "marble",
1560 "granite",
1561 "sandstone",
1562 "wood",
1563 "iron",
1564 "brass",
1565 "terracotta",
1566 "plaster",
1567 "moss",
1568 ] {
1569 let v = call_export(&pkg, mat, vec![]);
1570 let Value::Rec(m) = &v else {
1571 panic!("{mat} is a material")
1572 };
1573 assert!(m.contains_key("texture"), "{mat} carries a PBR recipe");
1574 let Value::Rec(t) = &m["texture"] else {
1575 panic!()
1576 };
1577 assert!(t.contains_key("colors") && t.contains_key("roughness"));
1578 }
1579 }
1580}