Skip to main content

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}