Skip to main content

thread_manifest/
markup.rs

1//! Thread markup — the HTML/CSS authoring surface for worlds.
2//!
3//! A `.thread` file reads like HTML + CSS and **compiles to a World Manifest**
4//! (the JSON "DOM" browsers fetch) — the same source→DOM split the web has. The
5//! goal is the low floor: a handful of tags and a `<style>` block get you a
6//! walkable place, no JSON by hand. Browsers accept `.thread` files directly
7//! (see [`WorldManifest::from_text`]); serving markup at `.well-known/thread/`
8//! is as valid as serving JSON — exactly like serving HTML.
9//!
10//! ```html
11//! <world id="grove" title="The Grove" description="A quiet clearing"
12//!        sky="0.1 0.1 0.2 / 0.5 0.3 0.2" rules="gathering">
13//!   <spawn at="0 0 8" yaw="180"/>
14//!   <tree class="harvestable" at="3 0 -2" scale="1 3 1"/>
15//!   <cube at="0 0 -5" rot="0 45 0" codex="the-thread">
16//!     <sphere at="0 1 0"/>
17//!   </cube>
18//!   <quad id="sign" at="0 1.4 -6" scale="5 2 0.2" url="https://example.com"/>
19//!   <portal to="thread://market" at="0 0 -8" label="Market"/>
20//! </world>
21//! <style>
22//!   tree { mesh: "trees/oak.glb" }
23//!   .harvestable {
24//!     interaction: "Chop";
25//!     interaction-hits: 3;
26//!     interaction-gives: 20100001 3;
27//!     interaction-message: "You chopped the old oak.";
28//!     interaction-despawns: true;
29//!   }
30//!   #sign { color: 0.2 0.25 0.35; emissive: 0.4 }
31//! </style>
32//! ```
33//!
34//! Grammar summary:
35//! - `<world id title description sky year rules>` — the root. `sky` is a named
36//!   preset (`dawn`/`day`/`dusk`/`night`/`void`) or explicit gradients
37//!   `"zr zg zb / hr hg hb [/ sx sy sz]"` (the third segment aims the sun).
38//!   `rules` opts into game mechanics (`survival gathering combat`), default none.
39//! - `<spawn at name yaw>` — an arrival point (`yaw` in degrees). A world with
40//!   no `<spawn>` gets a default one so every compiled world is enterable.
41//! - Any builtin tag (`cube`/`sphere`/`cylinder`/`capsule`/`plane`/`quad`) or a
42//!   tag given a `mesh:` style rule places an object. Attributes: `id`, `class`,
43//!   `at`, `rot` (Euler degrees, applied yaw→pitch→roll), `scale`,
44//!   `codex="slug"` (inspect opens its lore), `url="…"` (signboard → the web
45//!   reader), and `data-*` (arbitrary per-object data, numbers auto-typed).
46//!   Children nest — child transforms are relative to the parent.
47//! - `<portal to at rot scale label>` — a veil to another world.
48//! - **Structure vocabulary**: `<room at r h gates columns gate-width>` — a
49//!   round walled colonnaded chamber (gates are azimuths in degrees, 180 =
50//!   toward the default spawn; children are authored relative to the room's
51//!   centre); `<wall from="x z" to="x z" h thick>` — a straight wall between
52//!   two ground points. Architecture as elements, not hand-mathed cubes.
53//! - `light="r g b [intensity] [range]"` (attr or style property) — the
54//!   placement emits a point light; lamps are content, not configuration.
55//! - `<style>` properties: `mesh`, `color` (named or `r g b [a]`), `metallic`,
56//!   `roughness`, `emissive` (glow, blooms), `light`, room's `floor-color` /
57//!   `accent-color`, `interaction` (the verb) with longhands
58//!   `interaction-hits`, `interaction-gives` (`<item-id> [count]`),
59//!   `interaction-message`, `interaction-effect`, `interaction-despawns`.
60
61use std::collections::BTreeMap;
62
63use crate::{
64    Animate, Asset, AssetKind, Environment, Interaction, InteractionEffect, LightEmitter,
65    MaterialRef, MeshRef, Placement, Portal, Prefab, Sky, Spawn, StructuredId, StyleRule,
66    WorldManifest, WorldMeta, WorldRules,
67};
68
69/// The builtin-primitive mesh names a tag may resolve to directly (a bare
70/// `<cube>` needs no style rule); any other mesh comes from a `mesh:` cascade rule.
71const BUILTINS: &[&str] = &["cube", "sphere", "cylinder", "capsule", "plane", "quad"];
72
73/// A parsed markup element: a tag, its attributes, and its children.
74#[derive(Debug)]
75struct Element {
76    tag: String,
77    attrs: BTreeMap<String, String>,
78    children: Vec<Element>,
79}
80
81/// Compile Thread markup into a validated [`WorldManifest`].
82pub fn compile(src: &str) -> Result<WorldManifest, String> {
83    // Pull the <style> block out first (its body is CSS-ish, not markup).
84    let (markup, style_body) = extract_style(src);
85    let decls = parse_style(&style_body)?;
86    // `interaction` is a *runtime* cascade property → it rides along in the
87    // manifest's `styles`. Appearance (mesh/color/…) is resolved at compile time.
88    let styles = interaction_styles(&decls)?;
89
90    let roots = Parser::new(&markup).parse_all()?;
91    let world_el = roots
92        .iter()
93        .find(|e| e.tag == "world")
94        .ok_or("markup must contain a <world> root element")?;
95
96    let mut manifest = WorldManifest {
97        thread: crate::THREAD_VERSION.to_string(),
98        world: WorldMeta {
99            id: world_el
100                .attrs
101                .get("id")
102                .cloned()
103                .unwrap_or_else(|| "world".into()),
104            title: world_el
105                .attrs
106                .get("title")
107                .cloned()
108                .unwrap_or_else(|| "Untitled".into()),
109            description: world_el
110                .attrs
111                .get("description")
112                .cloned()
113                .unwrap_or_default(),
114            author: None,
115            codex: vec![],
116            license: None,
117            extra: Default::default(),
118        },
119        environment: Environment {
120            sky: parse_sky(world_el.attrs.get("sky")),
121            year: world_el.attrs.get("year").and_then(|y| y.parse().ok()),
122            rules: parse_rules(world_el.attrs.get("rules"))?,
123            ..Default::default()
124        },
125        spawns: vec![],
126        assets: vec![],
127        prefabs: vec![],
128        placements: vec![],
129        portals: vec![],
130        behaviors: vec![],
131        styles,
132        presence: parse_presence(world_el.attrs.get("presence"))?,
133        extra: Default::default(),
134    };
135
136    // Walk the world's children into placements/portals, synthesizing a prefab
137    // (and, for a mesh URL, an asset entry) per unique mesh the cascade resolves.
138    // A pre-pass collects <model> definitions — reusable composite parts that
139    // <use> stamps into place (the markup's "make a thing once" primitive).
140    let mut models: std::collections::HashMap<String, &Element> = Default::default();
141    for child in &world_el.children {
142        if child.tag == "model" {
143            let name = child
144                .attrs
145                .get("name")
146                .cloned()
147                .ok_or("<model> needs a `name` to be <use>d by")?;
148            models.insert(name, child);
149        }
150    }
151    let mut looks = LookRegistry::new();
152    for child in &world_el.children {
153        if child.tag == "shape" {
154            let name = child
155                .attrs
156                .get("name")
157                .cloned()
158                .ok_or("<shape> needs a `name` to be used by shape=\"…\"")?;
159            let resolution = child.attrs.get("resolution").and_then(|v| v.parse().ok());
160            let tree = parse_shape_children(&child.children)?;
161            tree.validate()?;
162            looks.shapes.insert(name, (tree, resolution));
163        }
164        if child.tag == "texture" {
165            let name = child
166                .attrs
167                .get("name")
168                .cloned()
169                .ok_or("<texture> needs a `name` to be used by texture=\"…\"")?;
170            let recipe = parse_texture_def(child, &looks.textures)?;
171            recipe.validate()?;
172            looks.textures.insert(name, recipe);
173        }
174    }
175    for child in &world_el.children {
176        if child.tag == "model" || child.tag == "shape" || child.tag == "texture" {
177            continue; // definitions emit nothing themselves
178        }
179        emit(child, &mut manifest, &decls, &mut looks, &models, 0)?;
180    }
181
182    // Every compiled world is enterable: a world that authored no <spawn> gets
183    // a default arrival point a few steps back from the origin, facing it.
184    if manifest.spawns.is_empty() {
185        manifest.spawns.push(Spawn {
186            name: "entry".into(),
187            position: [0.0, 0.0, 6.0],
188            yaw: std::f32::consts::PI,
189        });
190    }
191
192    manifest.validate().map_err(|e| e.to_string())?;
193    Ok(manifest)
194}
195
196/// Turn one markup element into a placement (or portal/spawn), recursing into
197/// children.
198fn emit(
199    el: &Element,
200    manifest: &mut WorldManifest,
201    decls: &[StyleDecl],
202    looks: &mut LookRegistry,
203    models: &std::collections::HashMap<String, &Element>,
204    depth: usize,
205) -> Result<(), String> {
206    if depth > 16 {
207        return Err("model expansion deeper than 16 — a <model> is <use>ing itself".into());
208    }
209    if el.tag == "spawn" {
210        manifest.spawns.push(Spawn {
211            name: el
212                .attrs
213                .get("name")
214                .cloned()
215                .unwrap_or_else(|| "entry".into()),
216            position: vec3(el.attrs.get("at"))?,
217            yaw: el
218                .attrs
219                .get("yaw")
220                .and_then(|y| y.parse::<f32>().ok())
221                .map(|deg| deg.to_radians())
222                .unwrap_or(0.0),
223        });
224        return Ok(());
225    }
226    // Structure vocabulary — architecture as elements, not hand-mathed cubes.
227    if el.tag == "room" {
228        return emit_room(el, manifest, decls, looks, models, depth);
229    }
230    if el.tag == "wall" {
231        return emit_wall(el, manifest, decls, looks);
232    }
233    if el.tag == "use" {
234        return emit_use(el, manifest, decls, looks, models, depth);
235    }
236    if el.tag == "ring" {
237        return emit_ring(el, manifest, decls, looks, models, depth);
238    }
239    if el.tag == "row" {
240        return emit_row(el, manifest, decls, looks, models, depth);
241    }
242    if el.tag == "lamp" {
243        return emit_lamp(el, manifest, decls, looks);
244    }
245    if el.tag == "model" {
246        return Err("<model> definitions live at the top level of <world>".into());
247    }
248    if el.tag == "portal" {
249        manifest.portals.push(Portal {
250            id: el
251                .attrs
252                .get("id")
253                .cloned()
254                .unwrap_or_else(|| "portal".into()),
255            position: vec3(el.attrs.get("at"))?,
256            rotation: rot_attr(el)?,
257            scale: scale_attr(el)?,
258            to: el.attrs.get("to").cloned().ok_or("<portal> needs a `to`")?,
259            label: el.attrs.get("label").cloned().unwrap_or_default(),
260            preview: Default::default(),
261            extra: Default::default(),
262        });
263        return Ok(());
264    }
265
266    // The element's mesh: a carved `shape="name"` wins; else a cascade `mesh:`
267    // rule; else a builtin-named tag (`<cube>`). Anything else needs a rule.
268    let mesh = if let Some(name) = el.attrs.get("shape") {
269        if !looks.shapes.contains_key(name) {
270            return Err(format!("shape=\"{name}\" — no such <shape> defined"));
271        }
272        format!("shape:{name}")
273    } else {
274        resolve_prop(el, decls, "mesh")
275            .or_else(|| BUILTINS.iter().find(|t| **t == el.tag).map(|t| (*t).to_string()))
276            .ok_or_else(|| {
277                format!(
278                    "no mesh for <{0}> — add a `{0} {{ mesh: \"…\" }}` style rule, or use a builtin tag",
279                    el.tag
280                )
281            })?
282    };
283    // The rest of the appearance cascade — each property resolved independently.
284    let texture = resolve_prop(el, decls, "texture");
285    if let Some(t) = &texture {
286        if !looks.textures.contains_key(t) {
287            return Err(format!("texture=\"{t}\" — no such <texture> defined"));
288        }
289    }
290    let look = Look {
291        mesh,
292        base_color: resolve_prop(el, decls, "color")
293            .and_then(|c| parse_color(&c))
294            .unwrap_or([1.0, 1.0, 1.0, 1.0]),
295        metallic: resolve_prop(el, decls, "metallic")
296            .and_then(|v| v.parse().ok())
297            .unwrap_or(0.0),
298        roughness: resolve_prop(el, decls, "roughness")
299            .and_then(|v| v.parse().ok())
300            .unwrap_or(1.0),
301        emissive: resolve_prop(el, decls, "emissive")
302            .map(|v| parse_emissive(&v))
303            .unwrap_or(0.0),
304        texture,
305    };
306    let prefab = looks.ensure(&look, manifest);
307
308    let mut children = Vec::new();
309    for c in &el.children {
310        // A child placement is authored relative to its parent (the tree model).
311        let before = manifest.placements.len();
312        emit(c, manifest, decls, looks, models, depth + 1)?;
313        // Move any placement `emit` pushed for the child under this one instead.
314        children.extend(manifest.placements.drain(before..));
315    }
316
317    let behavior = weft_binding(el, manifest);
318    manifest.placements.push(Placement {
319        prefab,
320        name: el.attrs.get("id").cloned().unwrap_or_default(),
321        kind: Some(el.tag.clone()),
322        class: el
323            .attrs
324            .get("class")
325            .map(|c| c.split_whitespace().map(str::to_string).collect())
326            .unwrap_or_default(),
327        position: vec3(el.attrs.get("at"))?,
328        rotation: rot_attr(el)?,
329        scale: scale_attr(el)?,
330        codex: el.attrs.get("codex").cloned(),
331        text: el.attrs.get("text").map(|t| {
332            let (content, links) = parse_text_links(&t.replace("\\n", "\n"));
333            crate::TextPanel {
334                content,
335                size: el
336                    .attrs
337                    .get("text-size")
338                    .and_then(|v| v.parse().ok())
339                    .unwrap_or_else(default_panel_size),
340                color: default_panel_ink(),
341                background: default_panel_paper(),
342                links,
343            }
344        }),
345        animate: parse_animate(resolve_prop(el, decls, "animate")),
346        solid: el
347            .attrs
348            .get("solid")
349            .map(|v| v != "false")
350            .or_else(|| resolve_prop(el, decls, "solid").map(|v| v != "false")),
351        light: el
352            .attrs
353            .get("light")
354            .cloned()
355            .or_else(|| resolve_prop(el, decls, "light"))
356            .and_then(|v| parse_light(&v)),
357        behavior,
358        interaction: None,
359        data: data_attrs(el),
360        children,
361        extra: Default::default(),
362    });
363    Ok(())
364}
365
366/// Inline hyperlinks in a `text` attribute — the markup form of the Thread's
367/// `<a>`: `[[thread://host/path|phrase]]` renders `phrase` as a link to the
368/// Locator; `[[thread://host/path]]` uses the Locator itself as the phrase.
369/// Returns the cleaned content plus the extracted [`crate::TextLink`]s.
370fn parse_text_links(raw: &str) -> (String, Vec<crate::TextLink>) {
371    let mut content = String::with_capacity(raw.len());
372    let mut links = Vec::new();
373    let mut rest = raw;
374    while let Some(open) = rest.find("[[") {
375        content.push_str(&rest[..open]);
376        let after = &rest[open + 2..];
377        let Some(close) = after.find("]]") else {
378            content.push_str(&rest[open..]);
379            rest = "";
380            break;
381        };
382        let inner = &after[..close];
383        let (to, label) = match inner.split_once('|') {
384            Some((to, label)) => (to.trim(), label.trim()),
385            None => (inner.trim(), inner.trim()),
386        };
387        if !to.is_empty() && !label.is_empty() {
388            links.push(crate::TextLink {
389                text: label.to_string(),
390                to: to.to_string(),
391            });
392            content.push_str(label);
393        }
394        rest = &after[close + 2..];
395    }
396    content.push_str(rest);
397    (content, links)
398}
399
400/// The `light` attribute / style property → a [`LightEmitter`]. Forms:
401/// `light="true"` (default warm lamplight), `light="r g b"`,
402/// `light="r g b intensity"`, `light="r g b intensity range"`.
403fn parse_light(v: &str) -> Option<LightEmitter> {
404    if v.trim() == "true" || v.trim().is_empty() {
405        return Some(LightEmitter {
406            color: [1.0, 0.85, 0.6],
407            intensity: 1.0,
408            range: 10.0,
409        });
410    }
411    let n: Vec<f32> = v
412        .split_whitespace()
413        .filter_map(|t| t.parse().ok())
414        .collect();
415    match n.as_slice() {
416        [r, g, b] => Some(LightEmitter {
417            color: [*r, *g, *b],
418            intensity: 1.0,
419            range: 10.0,
420        }),
421        [r, g, b, i] => Some(LightEmitter {
422            color: [*r, *g, *b],
423            intensity: *i,
424            range: 10.0,
425        }),
426        [r, g, b, i, ra] => Some(LightEmitter {
427            color: [*r, *g, *b],
428            intensity: *i,
429            range: *ra,
430        }),
431        _ => None,
432    }
433}
434
435/// Minimal angular distance between two azimuths, degrees.
436fn ang_dist(a: f32, b: f32) -> f32 {
437    let mut d = (a - b).abs() % 360.0;
438    if d > 180.0 {
439        d = 360.0 - d;
440    }
441    d
442}
443
444/// Push a bare structural placement (no text/behavior/children).
445#[allow(clippy::too_many_arguments)]
446// ── The carving language: <shape> subtrees → shape::Shape ────────────────
447//
448// Inside a <shape name="…"> definition, elements are SDF nodes, not
449// placements: primitives (<sphere> <box> <cylinder> <capsule> <cone>
450// <torus>), combiners (<blend k> <cut> <union> <intersect>), and <lathe
451// profile="r y, r y, …"> revolutions. The browser meshes the tree at load.
452
453fn parse_shape_children(children: &[Element]) -> Result<crate::shape::Shape, String> {
454    let mut parts = Vec::new();
455    for c in children {
456        parts.push(parse_shape_node(c)?);
457    }
458    match parts.len() {
459        0 => Err("<shape> needs at least one part".into()),
460        1 => Ok(parts.pop().unwrap()),
461        _ => Ok(crate::shape::Shape::Group(crate::shape::Group {
462            op: "union".into(),
463            k: 0.25,
464            at: [0.0; 3],
465            rot: 0.0,
466            parts,
467        })),
468    }
469}
470
471fn parse_shape_node(el: &Element) -> Result<crate::shape::Shape, String> {
472    use crate::shape::{Group, Lathe, Prim, Shape};
473    let f = |k: &str, d: f32| {
474        el.attrs
475            .get(k)
476            .and_then(|v| v.parse::<f32>().ok())
477            .unwrap_or(d)
478    };
479    let at = if el.attrs.contains_key("at") {
480        vec3(el.attrs.get("at"))?
481    } else {
482        [0.0; 3]
483    };
484    match el.tag.as_str() {
485        "sphere" | "cylinder" | "capsule" | "cone" | "torus" | "box" => {
486            let size = if el.tag == "box" {
487                Some(vec3(Some(
488                    el.attrs.get("size").ok_or("shape <box> needs a `size`")?,
489                ))?)
490            } else {
491                None
492            };
493            Ok(Shape::Prim(Prim {
494                prim: el.tag.clone(),
495                at,
496                rot: f("rot", 0.0),
497                r: f("r", 0.5),
498                size,
499                h: f("h", 1.0),
500                r2: f("r2", if el.tag == "torus" { 0.15 } else { 0.0 }),
501                rounded: f("rounded", 0.0),
502                axis: el.attrs.get("axis").cloned().unwrap_or_else(|| "y".into()),
503            }))
504        }
505        "blend" | "cut" | "union" | "intersect" => {
506            let mut parts = Vec::new();
507            for c in &el.children {
508                parts.push(parse_shape_node(c)?);
509            }
510            if parts.is_empty() {
511                return Err(format!("<{}> needs children", el.tag));
512            }
513            Ok(Shape::Group(Group {
514                op: el.tag.clone(),
515                k: f("k", 0.25),
516                at,
517                rot: f("rot", 0.0),
518                parts,
519            }))
520        }
521        "lathe" => {
522            let profile = el.attrs.get("profile").ok_or("<lathe> needs a `profile`")?;
523            let pts: Result<Vec<[f32; 2]>, String> = profile
524                .split(',')
525                .map(|pair| {
526                    let mut it = pair
527                        .split_whitespace()
528                        .filter_map(|t| t.parse::<f32>().ok());
529                    match (it.next(), it.next()) {
530                        (Some(r), Some(y)) => Ok([r, y]),
531                        _ => Err(format!("lathe profile point '{pair}' — want `r y`")),
532                    }
533                })
534                .collect();
535            Ok(Shape::Lathe(Lathe { lathe: pts?, at }))
536        }
537        other => Err(format!(
538            "'{other}' is not a shape word — prims {:?}, ops {:?}, or lathe",
539            crate::shape::PRIMS,
540            crate::shape::OPS
541        )),
542    }
543}
544
545/// `<texture name kind [scale octaves seed size] [colors="r g b | r g b …"]
546/// [rough="a b"] [metal="a b"] [height] [ao]/>` — one tag, a whole PBR
547/// material: the browser bakes albedo + normal + occlusion-roughness-metallic
548/// from the recipe. See texture.rs for the semantics.
549fn parse_texture_def(
550    el: &Element,
551    defined: &BTreeMap<String, crate::texture::TextureRecipe>,
552) -> Result<crate::texture::TextureRecipe, String> {
553    let f = |k: &str, d: f32| {
554        el.attrs
555            .get(k)
556            .and_then(|v| v.parse::<f32>().ok())
557            .unwrap_or(d)
558    };
559    let pair = |k: &str, d: [f32; 2]| -> Result<[f32; 2], String> {
560        match el.attrs.get(k) {
561            None => Ok(d),
562            Some(v) => {
563                let nums: Vec<f32> = v
564                    .split_whitespace()
565                    .filter_map(|t| t.parse().ok())
566                    .collect();
567                match nums.as_slice() {
568                    [a] => Ok([*a, *a]),
569                    [a, b] => Ok([*a, *b]),
570                    _ => Err(format!("<texture> {k}=\"{v}\" — want one or two numbers")),
571                }
572            }
573        }
574    };
575    let colors = match el.attrs.get("colors") {
576        None => vec![[0.6, 0.6, 0.6]],
577        Some(v) => v
578            .split('|')
579            .map(|c| {
580                let nums: Vec<f32> = c
581                    .split_whitespace()
582                    .filter_map(|t| t.parse().ok())
583                    .collect();
584                match nums.as_slice() {
585                    [r, g, b] => Ok([*r, *g, *b]),
586                    _ => Err(format!("<texture> color '{c}' — want `r g b`")),
587                }
588            })
589            .collect::<Result<Vec<_>, _>>()?,
590    };
591    // `over="name"` layers a previously defined texture on top (moss over
592    // granite) — defined earlier in the document, one level deep.
593    let over = match el.attrs.get("over") {
594        None => None,
595        Some(name) => Some(Box::new(defined.get(name).cloned().ok_or_else(|| {
596            format!("over=\"{name}\" — no such <texture> defined yet (define it first)")
597        })?)),
598    };
599    Ok(crate::texture::TextureRecipe {
600        kind: el
601            .attrs
602            .get("kind")
603            .cloned()
604            .ok_or("<texture> needs a `kind`")?,
605        scale: f("scale", 4.0),
606        octaves: f("octaves", 4.0) as u32,
607        seed: f("seed", 0.0) as u32,
608        colors,
609        roughness: pair("rough", [0.9, 0.9])?,
610        smoothness: match el.attrs.get("smooth") {
611            Some(_) => Some(pair("smooth", [0.1, 0.1])?),
612            None => None,
613        },
614        metallic: pair("metal", [0.0, 0.0])?,
615        height: f("height", 0.0),
616        ao: f("ao", 0.0),
617        size: f("size", 256.0) as u32,
618        over,
619        mix: f("mix", 0.5),
620        mask_scale: f("mask-scale", 3.0),
621        mask_seed: f("mask-seed", 0.0) as u32,
622        triplanar: f("triplanar", 0.0),
623    })
624}
625
626// ── The stamping engine: compile-time instancing ─────────────────────────
627//
628// <use>, <ring> and <row> all work the same way: emit a template's children
629// into the manifest, then TRANSFORM everything that was just produced
630// (translate + yaw + uniform scale). The manifest stays plain placements —
631// no runtime grouping, nothing new for browsers to learn — and the trig
632// lives here once instead of in every author's head.
633
634/// Emit `children` and transform the produced placements + portals by
635/// (`offset`, `yaw_deg`, uniform `scale`).
636fn stamp_children(
637    children: &[Element],
638    manifest: &mut WorldManifest,
639    decls: &[StyleDecl],
640    looks: &mut LookRegistry,
641    models: &std::collections::HashMap<String, &Element>,
642    depth: usize,
643    offset: [f32; 3],
644    yaw_deg: f32,
645    scale: f32,
646) -> Result<(), String> {
647    let p_before = manifest.placements.len();
648    let g_before = manifest.portals.len();
649    for c in children {
650        emit(c, manifest, decls, looks, models, depth + 1)?;
651    }
652    let q = euler_deg_to_quat(0.0, yaw_deg, 0.0);
653    let apply = |pos: &mut [f32; 3], rot: &mut [f32; 4], scl: Option<&mut [f32; 3]>| {
654        let scaled = [pos[0] * scale, pos[1] * scale, pos[2] * scale];
655        let turned = quat_rotate(q, scaled);
656        *pos = [
657            turned[0] + offset[0],
658            turned[1] + offset[1],
659            turned[2] + offset[2],
660        ];
661        *rot = quat_mul(q, *rot);
662        if let Some(s) = scl {
663            *s = [s[0] * scale, s[1] * scale, s[2] * scale];
664        }
665    };
666    for p in &mut manifest.placements[p_before..] {
667        apply(&mut p.position, &mut p.rotation, Some(&mut p.scale));
668        // Children of a stamped placement are parent-relative — untouched.
669    }
670    for portal in &mut manifest.portals[g_before..] {
671        apply(
672            &mut portal.position,
673            &mut portal.rotation,
674            Some(&mut portal.scale),
675        );
676    }
677    Ok(())
678}
679
680/// `<use model="name" at="x y z" [yaw=deg] [scale=s]/>` — stamp a `<model>`.
681fn emit_use(
682    el: &Element,
683    manifest: &mut WorldManifest,
684    decls: &[StyleDecl],
685    looks: &mut LookRegistry,
686    models: &std::collections::HashMap<String, &Element>,
687    depth: usize,
688) -> Result<(), String> {
689    let name = el.attrs.get("model").ok_or("<use> needs a `model` name")?;
690    let model = *models
691        .get(name)
692        .ok_or_else(|| format!("<use model=\"{name}\"> — no such <model> defined"))?;
693    let at = vec3(el.attrs.get("at"))?;
694    let yaw = el
695        .attrs
696        .get("yaw")
697        .and_then(|v| v.parse().ok())
698        .unwrap_or(0.0);
699    let scale = el
700        .attrs
701        .get("scale")
702        .and_then(|v| v.parse().ok())
703        .unwrap_or(1.0);
704    stamp_children(
705        &model.children,
706        manifest,
707        decls,
708        looks,
709        models,
710        depth,
711        at,
712        yaw,
713        scale,
714    )
715}
716
717/// `<ring n=8 r=10 [at] [start=deg] [face="in|out|none"]>…</ring>` — stamp the
718/// children at `n` evenly-spaced points of a circle, each turned to face the
719/// centre (the default), outward, or not at all. The radial-repetition
720/// primitive: lamps around a plaza, stones around a hall, veils around a home.
721fn emit_ring(
722    el: &Element,
723    manifest: &mut WorldManifest,
724    decls: &[StyleDecl],
725    looks: &mut LookRegistry,
726    models: &std::collections::HashMap<String, &Element>,
727    depth: usize,
728) -> Result<(), String> {
729    let c = el
730        .attrs
731        .get("at")
732        .map(|_| vec3(el.attrs.get("at")))
733        .transpose()?
734        .unwrap_or([0.0; 3]);
735    let n = el
736        .attrs
737        .get("n")
738        .and_then(|v| v.parse::<usize>().ok())
739        .unwrap_or(8)
740        .max(1);
741    let r = el
742        .attrs
743        .get("r")
744        .and_then(|v| v.parse::<f32>().ok())
745        .unwrap_or(8.0);
746    let start = el
747        .attrs
748        .get("start")
749        .and_then(|v| v.parse::<f32>().ok())
750        .unwrap_or(0.0);
751    let face = el.attrs.get("face").map(String::as_str).unwrap_or("in");
752    for k in 0..n {
753        let az = start + k as f32 * 360.0 / n as f32;
754        let (dx, dz) = crate::arch::az_dir(az);
755        let at = [c[0] + dx * r, c[1], c[2] + dz * r];
756        // Placement yaw ψ maps +Z (a quad's normal) to (sin ψ, cos ψ):
757        // facing in = toward the centre = −az_dir(az) = (−sin az, cos az) → ψ = −az
758        // (the same sign law as arch::Corridor::yaw_across — derived once, reused).
759        let yaw = match face {
760            "out" => 180.0 - az,
761            "none" => 0.0,
762            _ => -az,
763        };
764        stamp_children(
765            &el.children,
766            manifest,
767            decls,
768            looks,
769            models,
770            depth,
771            at,
772            yaw,
773            1.0,
774        )?;
775    }
776    Ok(())
777}
778
779/// `<row n=5 from="x y z" to="x y z" [yaw=deg]>…</row>` — stamp the children
780/// at `n` points evenly spaced from `from` to `to` (endpoints included). The
781/// linear-repetition primitive: a colonnade, a fence, a row of stalls.
782fn emit_row(
783    el: &Element,
784    manifest: &mut WorldManifest,
785    decls: &[StyleDecl],
786    looks: &mut LookRegistry,
787    models: &std::collections::HashMap<String, &Element>,
788    depth: usize,
789) -> Result<(), String> {
790    let a = vec3(el.attrs.get("from"))?;
791    let b = vec3(el.attrs.get("to"))?;
792    let n = el
793        .attrs
794        .get("n")
795        .and_then(|v| v.parse::<usize>().ok())
796        .unwrap_or(2)
797        .max(1);
798    let yaw = el
799        .attrs
800        .get("yaw")
801        .and_then(|v| v.parse().ok())
802        .unwrap_or(0.0);
803    for k in 0..n {
804        let t = if n == 1 {
805            0.5
806        } else {
807            k as f32 / (n - 1) as f32
808        };
809        let at = [
810            a[0] + (b[0] - a[0]) * t,
811            a[1] + (b[1] - a[1]) * t,
812            a[2] + (b[2] - a[2]) * t,
813        ];
814        stamp_children(
815            &el.children,
816            manifest,
817            decls,
818            looks,
819            models,
820            depth,
821            at,
822            yaw,
823            1.0,
824        )?;
825    }
826    Ok(())
827}
828
829/// `<lamp at="x y z" [h=2.2] [color="r g b"]/>` — a post, a warm head, and the
830/// light itself, in one element. Light is half of beauty; make it one tag.
831fn emit_lamp(
832    el: &Element,
833    manifest: &mut WorldManifest,
834    _decls: &[StyleDecl],
835    looks: &mut LookRegistry,
836) -> Result<(), String> {
837    let at = vec3(el.attrs.get("at"))?;
838    let h = el
839        .attrs
840        .get("h")
841        .and_then(|v| v.parse::<f32>().ok())
842        .unwrap_or(2.2);
843    let light_c = el
844        .attrs
845        .get("color")
846        .and_then(|v| parse_color(v))
847        .map(|c| [c[0], c[1], c[2]])
848        .unwrap_or([1.0, 0.78, 0.45]);
849    let post = looks.ensure(
850        &Look {
851            mesh: "cylinder".into(),
852            base_color: [0.20, 0.19, 0.22, 1.0],
853            metallic: 0.6,
854            roughness: 0.5,
855            emissive: 0.0,
856            texture: None,
857        },
858        manifest,
859    );
860    push_part(
861        manifest,
862        post,
863        "lamp post",
864        "lamp-post",
865        [at[0], at[1] + h / 2.0, at[2]],
866        0.0,
867        [0.16, h, 0.16],
868    );
869    let head = looks.ensure(
870        &Look {
871            mesh: "cube".into(),
872            base_color: [light_c[0], light_c[1] * 0.95, light_c[2] * 0.85, 1.0],
873            metallic: 0.0,
874            roughness: 0.2,
875            emissive: 0.6,
876            texture: None,
877        },
878        manifest,
879    );
880    let head_i = manifest.placements.len();
881    push_part(
882        manifest,
883        head,
884        "lamp",
885        "lamp-head",
886        [at[0], at[1] + h + 0.15, at[2]],
887        0.0,
888        [0.32, 0.32, 0.32],
889    );
890    manifest.placements[head_i].solid = Some(false);
891    manifest.placements[head_i].light = Some(crate::LightEmitter {
892        color: light_c,
893        intensity: el
894            .attrs
895            .get("intensity")
896            .and_then(|v| v.parse().ok())
897            .unwrap_or(1.3),
898        range: el
899            .attrs
900            .get("range")
901            .and_then(|v| v.parse().ok())
902            .unwrap_or(8.0),
903    });
904    Ok(())
905}
906
907/// Rotate vector `v` by quaternion `q` (`[x y z w]`).
908fn quat_rotate(q: [f32; 4], v: [f32; 3]) -> [f32; 3] {
909    let [qx, qy, qz, qw] = q;
910    // t = 2 q × v ; v' = v + w t + q × t
911    let t = [
912        2.0 * (qy * v[2] - qz * v[1]),
913        2.0 * (qz * v[0] - qx * v[2]),
914        2.0 * (qx * v[1] - qy * v[0]),
915    ];
916    [
917        v[0] + qw * t[0] + qy * t[2] - qz * t[1],
918        v[1] + qw * t[1] + qz * t[0] - qx * t[2],
919        v[2] + qw * t[2] + qx * t[1] - qy * t[0],
920    ]
921}
922
923fn push_part(
924    manifest: &mut WorldManifest,
925    prefab: StructuredId,
926    name: &str,
927    kind: &str,
928    position: [f32; 3],
929    yaw_deg: f32,
930    scale: [f32; 3],
931) {
932    manifest.placements.push(Placement {
933        prefab,
934        name: name.into(),
935        kind: Some(kind.into()),
936        class: vec![],
937        position,
938        rotation: euler_deg_to_quat(0.0, yaw_deg, 0.0),
939        scale,
940        codex: None,
941        text: None,
942        animate: None,
943        solid: None,
944        light: None,
945        behavior: None,
946        interaction: None,
947        data: serde_json::Value::Null,
948        children: vec![],
949        extra: Default::default(),
950    });
951}
952
953/// `<room at r h gates columns gate-width>` — a round, walled, colonnaded
954/// chamber: floor disc + wall segments (gapped at each gate azimuth) + columns
955/// with capitals at every joint. **Azimuth convention:** degrees where 0 faces
956/// −z (away from the default spawn) and 180 faces +z — so `gates="180"` opens
957/// the room toward arrivals. Style properties: `color` (walls), `floor-color`,
958/// `accent-color` (capitals). Children are authored relative to the room's
959/// centre — a room is a coordinate frame, like any parent.
960fn emit_room(
961    el: &Element,
962    manifest: &mut WorldManifest,
963    decls: &[StyleDecl],
964    looks: &mut LookRegistry,
965    models: &std::collections::HashMap<String, &Element>,
966    depth: usize,
967) -> Result<(), String> {
968    let c = vec3(el.attrs.get("at"))?;
969    let attr_f = |k: &str| el.attrs.get(k).and_then(|v| v.parse::<f32>().ok());
970    let r = attr_f("r").unwrap_or(12.0);
971    let h = attr_f("h").unwrap_or(3.6);
972    let n = attr_f("columns").map(|v| v as usize).unwrap_or(16).max(6);
973    let gate_w = attr_f("gate-width").unwrap_or(14.0);
974    let gates: Vec<f32> = el
975        .attrs
976        .get("gates")
977        .map(|g| {
978            g.split_whitespace()
979                .filter_map(|t| t.parse().ok())
980                .collect()
981        })
982        .unwrap_or_else(|| vec![180.0]);
983    let wall_c = resolve_prop(el, decls, "color")
984        .and_then(|v| parse_color(&v))
985        .unwrap_or([0.62, 0.58, 0.52, 1.0]);
986    let floor_c = resolve_prop(el, decls, "floor-color")
987        .and_then(|v| parse_color(&v))
988        .unwrap_or([0.50, 0.47, 0.42, 1.0]);
989    let accent_c = resolve_prop(el, decls, "accent-color")
990        .and_then(|v| parse_color(&v))
991        .unwrap_or([0.85, 0.68, 0.28, 1.0]);
992
993    let floor = looks.ensure(
994        &Look {
995            mesh: "cylinder".into(),
996            base_color: floor_c,
997            metallic: 0.0,
998            roughness: 0.9,
999            emissive: 0.0,
1000            texture: None,
1001        },
1002        manifest,
1003    );
1004    push_part(
1005        manifest,
1006        floor,
1007        "room floor",
1008        "room-floor",
1009        [c[0], c[1] + 0.03, c[2]],
1010        0.0,
1011        [r * 2.0, 0.06, r * 2.0],
1012    );
1013    let wall_pf = looks.ensure(
1014        &Look {
1015            mesh: "cube".into(),
1016            base_color: wall_c,
1017            metallic: 0.0,
1018            roughness: 0.85,
1019            emissive: 0.0,
1020            texture: None,
1021        },
1022        manifest,
1023    );
1024    let col_c = [wall_c[0] * 1.15, wall_c[1] * 1.15, wall_c[2] * 1.15, 1.0];
1025    let col_pf = looks.ensure(
1026        &Look {
1027            mesh: "cylinder".into(),
1028            base_color: col_c,
1029            metallic: 0.05,
1030            roughness: 0.55,
1031            emissive: 0.0,
1032            texture: None,
1033        },
1034        manifest,
1035    );
1036    let cap_pf = looks.ensure(
1037        &Look {
1038            mesh: "cylinder".into(),
1039            base_color: accent_c,
1040            metallic: 0.8,
1041            roughness: 0.35,
1042            emissive: 0.1,
1043            texture: None,
1044        },
1045        manifest,
1046    );
1047
1048    // One geometry for every builder: the shared architecture math (arch).
1049    let reach = crate::arch::gate_reach(n, gate_w);
1050    for s in crate::arch::ring_segments(r, n, &gates, reach) {
1051        push_part(
1052            manifest,
1053            wall_pf,
1054            "wall",
1055            "room-wall",
1056            [c[0] + s.x, c[1] + h / 2.0, c[2] + s.z],
1057            s.yaw_deg,
1058            [s.len + 0.2, h, 0.45],
1059        );
1060    }
1061    for (jx, jz) in crate::arch::ring_joints(r, n) {
1062        let col_h = h * 1.35;
1063        push_part(
1064            manifest,
1065            col_pf,
1066            "column",
1067            "room-column",
1068            [c[0] + jx, c[1] + col_h / 2.0, c[2] + jz],
1069            0.0,
1070            [0.5, col_h, 0.5],
1071        );
1072        push_part(
1073            manifest,
1074            cap_pf,
1075            "capital",
1076            "room-capital",
1077            [c[0] + jx, c[1] + col_h + 0.09, c[2] + jz],
1078            0.0,
1079            [0.7, 0.18, 0.7],
1080        );
1081    }
1082
1083    // Children live in the room's frame: emit, then translate to its centre
1084    // (portals and spawns authored inside a room move with it too).
1085    let (p0, po0, s0) = (
1086        manifest.placements.len(),
1087        manifest.portals.len(),
1088        manifest.spawns.len(),
1089    );
1090    for child in &el.children {
1091        emit(child, manifest, decls, looks, models, depth + 1)?;
1092    }
1093    for p in &mut manifest.placements[p0..] {
1094        for i in 0..3 {
1095            p.position[i] += c[i];
1096        }
1097    }
1098    for p in &mut manifest.portals[po0..] {
1099        for i in 0..3 {
1100            p.position[i] += c[i];
1101        }
1102    }
1103    for s in &mut manifest.spawns[s0..] {
1104        for i in 0..3 {
1105            s.position[i] += c[i];
1106        }
1107    }
1108    Ok(())
1109}
1110
1111/// `<wall from="x z" to="x z" h thick>` — a straight wall between two ground
1112/// points; height and thickness as attributes, colour from the cascade. The
1113/// piece missing from every blockout: rooms that aren't round.
1114fn emit_wall(
1115    el: &Element,
1116    manifest: &mut WorldManifest,
1117    decls: &[StyleDecl],
1118    looks: &mut LookRegistry,
1119) -> Result<(), String> {
1120    let two = |k: &str| -> Result<[f32; 2], String> {
1121        let s = el
1122            .attrs
1123            .get(k)
1124            .ok_or_else(|| format!("<wall> needs `{k}`"))?;
1125        let n: Vec<f32> = s
1126            .split_whitespace()
1127            .filter_map(|t| t.parse().ok())
1128            .collect();
1129        match n.as_slice() {
1130            [x, z] => Ok([*x, *z]),
1131            _ => Err(format!("<wall {k}> expects 2 numbers, got '{s}'")),
1132        }
1133    };
1134    let (from, to) = (two("from")?, two("to")?);
1135    let attr_f = |k: &str| el.attrs.get(k).and_then(|v| v.parse::<f32>().ok());
1136    let h = attr_f("h").unwrap_or(3.0);
1137    let thick = attr_f("thick").unwrap_or(0.4);
1138    let y = attr_f("y").unwrap_or(0.0);
1139    let (dx, dz) = (to[0] - from[0], to[1] - from[1]);
1140    let len = (dx * dx + dz * dz).sqrt().max(0.01);
1141    let yaw_deg = (-dz).atan2(dx).to_degrees();
1142    let color = resolve_prop(el, decls, "color")
1143        .and_then(|v| parse_color(&v))
1144        .unwrap_or([0.62, 0.58, 0.52, 1.0]);
1145    let pf = looks.ensure(
1146        &Look {
1147            mesh: "cube".into(),
1148            base_color: color,
1149            metallic: 0.0,
1150            roughness: 0.85,
1151            emissive: 0.0,
1152            texture: None,
1153        },
1154        manifest,
1155    );
1156    push_part(
1157        manifest,
1158        pf,
1159        &el.attrs.get("id").cloned().unwrap_or_else(|| "wall".into()),
1160        "wall",
1161        [
1162            (from[0] + to[0]) / 2.0,
1163            y + h / 2.0,
1164            (from[1] + to[1]) / 2.0,
1165        ],
1166        yaw_deg,
1167        [len, h, thick],
1168    );
1169    Ok(())
1170}
1171
1172/// Per-object data from `url="…"` and `data-*` attributes. Values that parse as
1173/// numbers or booleans are typed as such (a `data-price="250"` is a number the
1174/// commerce layer can use); everything else stays a string.
1175fn data_attrs(el: &Element) -> serde_json::Value {
1176    let mut map = serde_json::Map::new();
1177    if let Some(url) = el.attrs.get("url") {
1178        map.insert("url".into(), serde_json::Value::String(url.clone()));
1179    }
1180    for (k, v) in &el.attrs {
1181        if let Some(key) = k.strip_prefix("data-") {
1182            map.insert(key.to_string(), typed_value(v));
1183        }
1184    }
1185    if map.is_empty() {
1186        serde_json::Value::Null
1187    } else {
1188        serde_json::Value::Object(map)
1189    }
1190}
1191
1192/// Type a raw attribute value: bool, number, or string.
1193fn typed_value(v: &str) -> serde_json::Value {
1194    match v {
1195        "true" => return serde_json::Value::Bool(true),
1196        "false" => return serde_json::Value::Bool(false),
1197        _ => {}
1198    }
1199    if let Ok(n) = v.parse::<i64>() {
1200        return serde_json::Value::Number(n.into());
1201    }
1202    if let Ok(f) = v.parse::<f64>() {
1203        if let Some(n) = serde_json::Number::from_f64(f) {
1204            return serde_json::Value::Number(n);
1205        }
1206    }
1207    serde_json::Value::String(v.to_string())
1208}
1209
1210/// The `rules="survival gathering combat"` attribute → [`WorldRules`]. Unknown
1211/// tokens are an error (a typo silently defaulting to "off" would be cruel).
1212/// The `<world presence="…">` attribute — the whole of a creator's presence
1213/// setup, one word (or one address):
1214///
1215/// - absent / `"none"` — people off (Tier 0, solo). The default.
1216/// - `"p2p"` — participants host each other; the first traveler whose browser
1217///   allows hosting becomes the room's host (presence-topology §3).
1218/// - a `wss://` (or `ws://`) relay URL — hosted: the creator runs (or rents)
1219///   a relay and names it. Comma-separate for ordered fallbacks.
1220fn parse_presence(s: Option<&String>) -> Result<Option<crate::Presence>, String> {
1221    let Some(s) = s else { return Ok(None) };
1222    let v = s.trim();
1223    match v {
1224        "" | "none" | "off" | "solo" => Ok(None),
1225        "p2p" => Ok(Some(crate::Presence {
1226            mode: Some("p2p".into()),
1227            relay: None,
1228            relays: vec![],
1229            rendezvous: None,
1230            max_occupants: None,
1231            voice: true,
1232            owner_required: false,
1233            extra: Default::default(),
1234        })),
1235        urls if urls.starts_with("wss://") || urls.starts_with("ws://") => {
1236            let relays: Vec<String> = urls
1237                .split(',')
1238                .map(str::trim)
1239                .filter(|u| !u.is_empty())
1240                .map(String::from)
1241                .collect();
1242            Ok(Some(crate::Presence {
1243                mode: Some("relay".into()),
1244                relay: None,
1245                relays,
1246                rendezvous: None,
1247                max_occupants: None,
1248                voice: true,
1249                owner_required: false,
1250                extra: Default::default(),
1251            }))
1252        }
1253        other => Err(format!(
1254            "world presence '{other}' — use \"none\", \"p2p\", or a wss:// relay URL"
1255        )),
1256    }
1257}
1258
1259fn parse_rules(s: Option<&String>) -> Result<WorldRules, String> {
1260    let mut rules = WorldRules::default();
1261    let Some(s) = s else { return Ok(rules) };
1262    for tok in s.split([' ', ',']).filter(|t| !t.is_empty()) {
1263        match tok {
1264            "survival" => rules.survival = true,
1265            "gathering" => rules.gathering = true,
1266            "combat" => rules.combat = true,
1267            other => {
1268                return Err(format!(
1269                    "unknown rule '{other}' (survival|gathering|combat)"
1270                ))
1271            }
1272        }
1273    }
1274    Ok(rules)
1275}
1276
1277/// A parsed `<style>` rule: a selector + its declared properties (raw values) —
1278/// `mesh`, `color`, `metallic`, `roughness`, `emissive`, `interaction[-…]`.
1279struct StyleDecl {
1280    select: String,
1281    props: BTreeMap<String, String>,
1282}
1283
1284/// Build the manifest's runtime [`StyleRule`]s from the declarations: every rule
1285/// that declares an `interaction` verb becomes a full [`Interaction`], with the
1286/// `interaction-*` longhands filling hits + effects.
1287fn interaction_styles(decls: &[StyleDecl]) -> Result<Vec<StyleRule>, String> {
1288    let mut styles = Vec::new();
1289    for d in decls {
1290        let Some(label) = d.props.get("interaction") else {
1291            // Longhands without a verb are a mistake worth catching early.
1292            if d.props.keys().any(|k| k.starts_with("interaction-")) {
1293                return Err(format!(
1294                    "style rule '{}' has interaction-* longhands but no `interaction:` verb",
1295                    d.select
1296                ));
1297            }
1298            continue;
1299        };
1300        let hits = match d.props.get("interaction-hits") {
1301            Some(h) => h
1302                .parse()
1303                .map_err(|_| format!("bad interaction-hits '{h}'"))?,
1304            None => 1,
1305        };
1306        let mut effects = Vec::new();
1307        if let Some(gives) = d.props.get("interaction-gives") {
1308            effects.push(parse_gives(gives)?);
1309        }
1310        if let Some(fx) = d.props.get("interaction-effect") {
1311            effects.push(InteractionEffect::Effect(fx.clone()));
1312        }
1313        if let Some(msg) = d.props.get("interaction-message") {
1314            effects.push(InteractionEffect::Message(msg.clone()));
1315        }
1316        if d.props
1317            .get("interaction-despawns")
1318            .is_some_and(|v| v != "false")
1319        {
1320            effects.push(InteractionEffect::Despawn);
1321        }
1322        styles.push(StyleRule {
1323            select: d.select.clone(),
1324            interaction: Some(Interaction {
1325                label: label.clone(),
1326                hits,
1327                effects,
1328            }),
1329        });
1330    }
1331    Ok(styles)
1332}
1333
1334/// `interaction-gives: <item-id> [count]` — an item StructuredId plus a count.
1335fn parse_gives(v: &str) -> Result<InteractionEffect, String> {
1336    let mut toks = v.split_whitespace();
1337    let id: u32 = toks
1338        .next()
1339        .and_then(|t| t.parse().ok())
1340        .ok_or_else(|| format!("interaction-gives needs an item id, got '{v}'"))?;
1341    let count = match toks.next() {
1342        Some(c) => c
1343            .parse()
1344            .map_err(|_| format!("bad interaction-gives count in '{v}'"))?,
1345        None => 1,
1346    };
1347    Ok(InteractionEffect::GiveItem {
1348        item: StructuredId(id),
1349        count,
1350    })
1351}
1352
1353/// Match an element against a simple selector, returning CSS specificity if it
1354/// matches (id 2 > class 1 > type 0).
1355fn sel_specificity(el: &Element, select: &str) -> Option<u8> {
1356    let s = select.trim();
1357    if let Some(id) = s.strip_prefix('#') {
1358        return (el.attrs.get("id").map(String::as_str) == Some(id)).then_some(2);
1359    }
1360    if let Some(c) = s.strip_prefix('.') {
1361        let classes = el.attrs.get("class").map(String::as_str).unwrap_or("");
1362        return classes.split_whitespace().any(|x| x == c).then_some(1);
1363    }
1364    (el.tag == s).then_some(0)
1365}
1366
1367/// The value a property cascades to for this element: highest-specificity rule
1368/// that declares it (later rules break ties). Independent per property, like CSS.
1369fn resolve_prop(el: &Element, decls: &[StyleDecl], prop: &str) -> Option<String> {
1370    // Inline beats the cascade — exactly the web's rule. (This was broken
1371    // once: inline `color=` silently dropped and whole worlds rendered white;
1372    // the snapshot loop caught it. Keep this line first.)
1373    if let Some(v) = el.attrs.get(prop) {
1374        return Some(v.clone());
1375    }
1376    let mut best: Option<(u8, &str)> = None;
1377    for d in decls {
1378        if let (Some(spec), Some(v)) = (sel_specificity(el, &d.select), d.props.get(prop)) {
1379            if best.is_none_or(|(s, _)| spec >= s) {
1380                best = Some((spec, v));
1381            }
1382        }
1383    }
1384    best.map(|(_, v)| v.to_string())
1385}
1386
1387/// The resolved appearance of an element: a mesh plus its PBR material scalars.
1388struct Look {
1389    mesh: String,
1390    base_color: [f32; 4],
1391    metallic: f32,
1392    roughness: f32,
1393    /// >0 renders unlit, glowing its base colour (blooms) — `emissive: true` = 1.
1394    emissive: f32,
1395    /// Named procedural material (`texture="granite"`), baked by the browser.
1396    texture: Option<String>,
1397}
1398
1399impl Look {
1400    /// A stable key so identical looks share one synthesized prefab.
1401    fn key(&self) -> String {
1402        format!(
1403            "{}|{:?}|{}|{}|{}|{:?}",
1404            self.mesh, self.base_color, self.metallic, self.roughness, self.emissive, self.texture
1405        )
1406    }
1407    fn is_plain(&self) -> bool {
1408        self.base_color == [1.0, 1.0, 1.0, 1.0]
1409            && self.metallic == 0.0
1410            && self.roughness == 1.0
1411            && self.emissive == 0.0
1412            && self.texture.is_none()
1413    }
1414}
1415
1416/// `emissive: true` (full glow) or a strength number.
1417fn parse_emissive(v: &str) -> f32 {
1418    match v {
1419        "true" => 1.0,
1420        _ => v.parse().unwrap_or(0.0),
1421    }
1422}
1423
1424/// Named colours the `color:` property accepts, besides `r g b [a]` floats.
1425fn parse_color(s: &str) -> Option<[f32; 4]> {
1426    let named = match s.trim() {
1427        "white" => [1.0, 1.0, 1.0, 1.0],
1428        "black" => [0.0, 0.0, 0.0, 1.0],
1429        "red" => [0.8, 0.15, 0.15, 1.0],
1430        "green" => [0.2, 0.6, 0.2, 1.0],
1431        "blue" => [0.2, 0.4, 0.8, 1.0],
1432        "yellow" => [0.9, 0.85, 0.2, 1.0],
1433        "orange" => [0.9, 0.5, 0.15, 1.0],
1434        "brown" => [0.4, 0.26, 0.13, 1.0],
1435        "gray" | "grey" => [0.5, 0.5, 0.5, 1.0],
1436        _ => {
1437            let n: Vec<f32> = s
1438                .split_whitespace()
1439                .filter_map(|t| t.parse().ok())
1440                .collect();
1441            return match n.as_slice() {
1442                [r, g, b] => Some([*r, *g, *b, 1.0]),
1443                [r, g, b, a] => Some([*r, *g, *b, *a]),
1444                _ => None,
1445            };
1446        }
1447    };
1448    Some(named)
1449}
1450
1451/// Synthesizes one prefab per unique *look* (mesh + material), plus one `assets[]`
1452/// entry per unique mesh URL — so cascaded appearance renders through the normal
1453/// prefab pipeline. URLs may be local paths or CDN/ipfs URIs.
1454struct LookRegistry {
1455    prefabs: BTreeMap<String, StructuredId>,
1456    assets: BTreeMap<String, String>,
1457    /// Named `<shape>` recipes (tree + meshing resolution), by name.
1458    shapes: BTreeMap<String, (crate::shape::Shape, Option<u32>)>,
1459    /// Named `<texture>` recipes, by name.
1460    textures: BTreeMap<String, crate::texture::TextureRecipe>,
1461    next_prefab: u32,
1462    next_asset: u32,
1463}
1464
1465impl LookRegistry {
1466    fn new() -> Self {
1467        Self {
1468            prefabs: BTreeMap::new(),
1469            assets: BTreeMap::new(),
1470            shapes: BTreeMap::new(),
1471            textures: BTreeMap::new(),
1472            next_prefab: 60910001,
1473            next_asset: 0,
1474        }
1475    }
1476
1477    fn ensure(&mut self, look: &Look, manifest: &mut WorldManifest) -> StructuredId {
1478        if let Some(id) = self.prefabs.get(&look.key()) {
1479            return *id;
1480        }
1481        let mesh_ref = if BUILTINS.contains(&look.mesh.as_str()) {
1482            MeshRef {
1483                asset: None,
1484                builtin: Some(look.mesh.clone()),
1485                shape: None,
1486                resolution: None,
1487            }
1488        } else if let Some(name) = look.mesh.strip_prefix("shape:") {
1489            // A carved shape: the recipe rides in the prefab itself. The name
1490            // was checked against the registry at the usage site.
1491            let (tree, resolution) = self
1492                .shapes
1493                .get(name)
1494                .cloned()
1495                .expect("shape checked at usage site");
1496            MeshRef {
1497                asset: None,
1498                builtin: None,
1499                shape: Some(tree),
1500                resolution,
1501            }
1502        } else {
1503            // Dedup the asset URL across looks that share the mesh but differ in colour.
1504            let asset_id = match self.assets.get(&look.mesh) {
1505                Some(a) => a.clone(),
1506                None => {
1507                    let aid = format!("mesh-{}", self.next_asset);
1508                    self.next_asset += 1;
1509                    manifest.assets.push(Asset {
1510                        id: aid.clone(),
1511                        uri: look.mesh.clone(),
1512                        kind: AssetKind::Gltf,
1513                    });
1514                    self.assets.insert(look.mesh.clone(), aid.clone());
1515                    aid
1516                }
1517            };
1518            MeshRef {
1519                asset: Some(asset_id),
1520                builtin: None,
1521                shape: None,
1522                resolution: None,
1523            }
1524        };
1525        let material = (!look.is_plain()).then(|| MaterialRef {
1526            base_color: look.base_color,
1527            metallic: look.metallic,
1528            roughness: look.roughness,
1529            emissive: look.emissive,
1530            texture: look
1531                .texture
1532                .as_ref()
1533                .and_then(|t| self.textures.get(t).cloned()),
1534            ..Default::default()
1535        });
1536        let id = StructuredId(self.next_prefab);
1537        self.next_prefab += 1;
1538        manifest.prefabs.push(Prefab {
1539            id,
1540            mesh: mesh_ref,
1541            material,
1542            extra: Default::default(),
1543        });
1544        self.prefabs.insert(look.key(), id);
1545        id
1546    }
1547}
1548
1549fn default_panel_size() -> f32 {
1550    0.09
1551}
1552fn default_panel_ink() -> [f32; 3] {
1553    [0.13, 0.11, 0.10]
1554}
1555fn default_panel_paper() -> [f32; 3] {
1556    [0.93, 0.89, 0.80]
1557}
1558
1559/// The `weft="<uri>"` attribute — bind a **Weft** module (the Thread's native
1560/// code, weft-v0.1) to this object: synthesizes the asset + behavior
1561/// declarations and returns the binding id. One line of markup = verified,
1562/// fuel-metered interactivity.
1563fn weft_binding(el: &Element, manifest: &mut WorldManifest) -> Option<String> {
1564    // `weft-on="interact tick"` picks the events; default stays interact-only.
1565    // A `tick` subscription gives the module the world's heartbeat.
1566    let on: Vec<String> = el
1567        .attrs
1568        .get("weft-on")
1569        .map(|v| v.split_whitespace().map(str::to_string).collect())
1570        .filter(|v: &Vec<String>| !v.is_empty())
1571        .unwrap_or_else(|| vec!["interact".into()]);
1572    let bid = format!("weft-b{}", manifest.behaviors.len());
1573    // `weft-use="<uri>#<export>"` — bind a **published package's export**
1574    // (weft-pack-v0.1): one line of markup consumes the ecosystem.
1575    if let Some(spec) = el.attrs.get("weft-use") {
1576        let (uri, export) = spec.split_once('#')?;
1577        if uri.is_empty() || export.is_empty() {
1578            return None;
1579        }
1580        let aid = format!("weft-a{}", manifest.assets.len());
1581        manifest.assets.push(Asset {
1582            id: aid.clone(),
1583            uri: uri.to_string(),
1584            kind: AssetKind::Weft,
1585        });
1586        manifest.behaviors.push(crate::Behavior {
1587            id: bid.clone(),
1588            wasm: String::new(),
1589            weft: None,
1590            weft_pack: Some(aid),
1591            weft_export: Some(export.to_string()),
1592            on,
1593        });
1594        return Some(bid);
1595    }
1596    let uri = el.attrs.get("weft")?;
1597    let aid = format!("weft-a{}", manifest.assets.len());
1598    manifest.assets.push(Asset {
1599        id: aid.clone(),
1600        uri: uri.clone(),
1601        kind: AssetKind::Weft,
1602    });
1603    manifest.behaviors.push(crate::Behavior {
1604        id: bid.clone(),
1605        wasm: String::new(),
1606        weft: Some(aid),
1607        weft_pack: None,
1608        weft_export: None,
1609        on,
1610    });
1611    Some(bid)
1612}
1613
1614/// The `animate` style property: `animate: spin [speed]` or
1615/// `animate: bob [speed] [amp]` — data-driven idle motion (browser-executed).
1616fn parse_animate(v: Option<String>) -> Option<Animate> {
1617    let v = v?;
1618    let mut toks = v.split_whitespace();
1619    let kind = toks.next()?.to_string();
1620    let speed = toks.next().and_then(|t| t.parse().ok()).unwrap_or(1.0);
1621    let amp = toks.next().and_then(|t| t.parse().ok()).unwrap_or(0.25);
1622    Some(Animate {
1623        kind,
1624        speed,
1625        amp,
1626        points: vec![],
1627    })
1628}
1629
1630// --- attribute parsing ---
1631
1632fn vec3(s: Option<&String>) -> Result<[f32; 3], String> {
1633    let s = s.map(String::as_str).unwrap_or("0 0 0");
1634    let nums: Vec<f32> = s
1635        .split_whitespace()
1636        .filter_map(|t| t.parse().ok())
1637        .collect();
1638    match nums.as_slice() {
1639        [x, y, z] => Ok([*x, *y, *z]),
1640        _ => Err(format!("expected 3 numbers, got '{s}'")),
1641    }
1642}
1643
1644/// The `scale` attribute (default unit).
1645fn scale_attr(el: &Element) -> Result<[f32; 3], String> {
1646    match el.attrs.get("scale") {
1647        Some(_) => vec3(el.attrs.get("scale")),
1648        None => Ok([1.0, 1.0, 1.0]),
1649    }
1650}
1651
1652/// The `rot="x y z"` attribute — Euler angles in **degrees**, applied yaw (Y),
1653/// then pitch (X), then roll (Z) — as a unit quaternion `[x y z w]`.
1654fn rot_attr(el: &Element) -> Result<[f32; 4], String> {
1655    match el.attrs.get("rot") {
1656        None => Ok([0.0, 0.0, 0.0, 1.0]),
1657        Some(_) => {
1658            let [x, y, z] = vec3(el.attrs.get("rot"))?;
1659            Ok(euler_deg_to_quat(x, y, z))
1660        }
1661    }
1662}
1663
1664/// Euler degrees (x pitch, y yaw, z roll) → quaternion `[x y z w]`, composed
1665/// `qy * qx * qz` (yaw, then pitch, then roll — the common authoring intuition).
1666fn euler_deg_to_quat(x: f32, y: f32, z: f32) -> [f32; 4] {
1667    let (hx, hy, hz) = (
1668        x.to_radians() / 2.0,
1669        y.to_radians() / 2.0,
1670        z.to_radians() / 2.0,
1671    );
1672    let qx = [hx.sin(), 0.0, 0.0, hx.cos()];
1673    let qy = [0.0, hy.sin(), 0.0, hy.cos()];
1674    let qz = [0.0, 0.0, hz.sin(), hz.cos()];
1675    quat_mul(quat_mul(qy, qx), qz)
1676}
1677
1678/// Hamilton product of two `[x y z w]` quaternions.
1679fn quat_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
1680    let [ax, ay, az, aw] = a;
1681    let [bx, by, bz, bw] = b;
1682    [
1683        aw * bx + bw * ax + ay * bz - az * by,
1684        aw * by + bw * ay + az * bx - ax * bz,
1685        aw * bz + bw * az + ax * by - ay * bx,
1686        aw * bw - ax * bx - ay * by - az * bz,
1687    ]
1688}
1689
1690/// `sky` accepts a named preset (`dawn`/`day`/`dusk`/`night`/`void`) — a tuned
1691/// zenith + horizon + sun that just looks good — or explicit gradients:
1692/// `"zr zg zb / hr hg hb"`, with an optional third segment for the sun
1693/// direction (`"… / … / sx sy sz"`).
1694fn parse_sky(s: Option<&String>) -> Option<Sky> {
1695    let s = s?;
1696    if let Some(preset) = sky_preset(s.trim()) {
1697        return Some(preset);
1698    }
1699    let mut segs = s.split('/');
1700    let z: Vec<f32> = segs
1701        .next()?
1702        .split_whitespace()
1703        .filter_map(|t| t.parse().ok())
1704        .collect();
1705    let h: Vec<f32> = segs
1706        .next()?
1707        .split_whitespace()
1708        .filter_map(|t| t.parse().ok())
1709        .collect();
1710    let sun: Vec<f32> = segs
1711        .next()
1712        .map(|seg| {
1713            seg.split_whitespace()
1714                .filter_map(|t| t.parse().ok())
1715                .collect()
1716        })
1717        .unwrap_or_default();
1718    if let ([zx, zy, zz], [hx, hy, hz]) = (z.as_slice(), h.as_slice()) {
1719        let sun_dir = match sun.as_slice() {
1720            [sx, sy, sz] => Some([*sx, *sy, *sz]),
1721            _ => None,
1722        };
1723        Some(Sky {
1724            zenith: [*zx, *zy, *zz],
1725            horizon: [*hx, *hy, *hz],
1726            sun_dir,
1727        })
1728    } else {
1729        None
1730    }
1731}
1732
1733/// The named sky presets — each a complete atmosphere (gradient + sun angle)
1734/// tuned for the standard rendering loop, so a world looks good by naming a
1735/// time of day instead of tuning numbers.
1736fn sky_preset(name: &str) -> Option<Sky> {
1737    let (zenith, horizon, sun) = match name {
1738        "dawn" => ([0.30, 0.38, 0.60], [0.95, 0.62, 0.42], [0.75, 0.22, 0.35]),
1739        "day" | "noon" => ([0.24, 0.48, 0.85], [0.72, 0.82, 0.92], [0.30, 0.85, 0.25]),
1740        "dusk" => ([0.22, 0.16, 0.38], [0.92, 0.44, 0.28], [-0.70, 0.16, 0.40]),
1741        "night" => ([0.02, 0.03, 0.08], [0.07, 0.09, 0.16], [0.40, 0.20, 0.50]),
1742        "void" => ([0.05, 0.06, 0.12], [0.18, 0.16, 0.24], [0.30, 0.70, 0.20]),
1743        _ => return None,
1744    };
1745    Some(Sky {
1746        zenith,
1747        horizon,
1748        sun_dir: Some(sun),
1749    })
1750}
1751
1752// --- <style> block ---
1753
1754/// Split out the `<style>…</style>` body (if any) from the markup.
1755fn extract_style(src: &str) -> (String, String) {
1756    if let (Some(open), Some(close)) = (src.find("<style>"), src.find("</style>")) {
1757        if close > open {
1758            let body = src[open + "<style>".len()..close].to_string();
1759            let markup = format!("{}{}", &src[..open], &src[close + "</style>".len()..]);
1760            return (markup, body);
1761        }
1762    }
1763    (src.to_string(), String::new())
1764}
1765
1766/// Parse a `<style>` body into per-selector property maps (raw string values).
1767/// A block is `selector { prop: value; prop: value }`; empty blocks are skipped.
1768fn parse_style(body: &str) -> Result<Vec<StyleDecl>, String> {
1769    let mut decls = Vec::new();
1770    for block in body.split('}') {
1771        let block = block.trim();
1772        if block.is_empty() {
1773            continue;
1774        }
1775        let (select, body) = block
1776            .split_once('{')
1777            .ok_or_else(|| format!("style rule missing '{{': {block}"))?;
1778        let mut props = BTreeMap::new();
1779        for decl in body.split(';') {
1780            if let Some((prop, val)) = decl.trim().split_once(':') {
1781                let val = val.trim().trim_matches('"').to_string();
1782                if !val.is_empty() {
1783                    props.insert(prop.trim().to_string(), val);
1784                }
1785            }
1786        }
1787        if !props.is_empty() {
1788            decls.push(StyleDecl {
1789                select: select.trim().to_string(),
1790                props,
1791            });
1792        }
1793    }
1794    Ok(decls)
1795}
1796
1797// --- markup parser (a tiny XML-ish recursive descent) ---
1798
1799struct Parser<'a> {
1800    s: &'a [u8],
1801    i: usize,
1802}
1803
1804impl<'a> Parser<'a> {
1805    fn new(s: &'a str) -> Self {
1806        Self {
1807            s: s.as_bytes(),
1808            i: 0,
1809        }
1810    }
1811
1812    fn parse_all(&mut self) -> Result<Vec<Element>, String> {
1813        self.parse_children(None)
1814    }
1815
1816    /// Parse sibling elements until EOF, or until the closing tag `until`.
1817    fn parse_children(&mut self, until: Option<&str>) -> Result<Vec<Element>, String> {
1818        let mut out = Vec::new();
1819        loop {
1820            self.skip_trivia();
1821            if self.i >= self.s.len() {
1822                return match until {
1823                    Some(u) => Err(format!("unclosed <{u}>")),
1824                    None => Ok(out),
1825                };
1826            }
1827            if self.starts_with("</") {
1828                self.i += 2;
1829                let name = self.read_name();
1830                self.expect('>')?;
1831                return match until {
1832                    Some(u) if u == name => Ok(out),
1833                    Some(u) => Err(format!("mismatched close </{name}> (wanted </{u}>)")),
1834                    None => Err(format!("unexpected close </{name}>")),
1835                };
1836            }
1837            if self.peek() == Some(b'<') {
1838                out.push(self.parse_element()?);
1839            } else {
1840                // Text between tags is ignored — worlds are element trees.
1841                while self.i < self.s.len() && self.s[self.i] != b'<' {
1842                    self.i += 1;
1843                }
1844            }
1845        }
1846    }
1847
1848    fn parse_element(&mut self) -> Result<Element, String> {
1849        self.expect('<')?;
1850        let tag = self.read_name();
1851        if tag.is_empty() {
1852            return Err("empty tag name".into());
1853        }
1854        let mut attrs = BTreeMap::new();
1855        loop {
1856            self.skip_ws();
1857            match self.peek() {
1858                Some(b'/') => {
1859                    self.i += 1;
1860                    self.expect('>')?;
1861                    return Ok(Element {
1862                        tag,
1863                        attrs,
1864                        children: vec![],
1865                    });
1866                }
1867                Some(b'>') => {
1868                    self.i += 1;
1869                    let children = self.parse_children(Some(&tag))?;
1870                    return Ok(Element {
1871                        tag,
1872                        attrs,
1873                        children,
1874                    });
1875                }
1876                Some(_) => {
1877                    let (k, v) = self.read_attr()?;
1878                    attrs.insert(k, v);
1879                }
1880                None => return Err(format!("unclosed <{tag}>")),
1881            }
1882        }
1883    }
1884
1885    fn read_attr(&mut self) -> Result<(String, String), String> {
1886        let name = self.read_name();
1887        if name.is_empty() {
1888            return Err("expected attribute name".into());
1889        }
1890        self.skip_ws();
1891        if self.peek() != Some(b'=') {
1892            return Ok((name, String::new())); // valueless attribute
1893        }
1894        self.i += 1;
1895        self.skip_ws();
1896        let quote = self
1897            .peek()
1898            .filter(|c| *c == b'"' || *c == b'\'')
1899            .ok_or("attribute value must be quoted")?;
1900        self.i += 1;
1901        let start = self.i;
1902        while self.i < self.s.len() && self.s[self.i] != quote {
1903            self.i += 1;
1904        }
1905        let val = std::str::from_utf8(&self.s[start..self.i])
1906            .unwrap_or_default()
1907            .to_string();
1908        self.expect(quote as char)?;
1909        Ok((name, val))
1910    }
1911
1912    fn read_name(&mut self) -> String {
1913        let start = self.i;
1914        while self.i < self.s.len() {
1915            let c = self.s[self.i];
1916            if c.is_ascii_alphanumeric() || c == b'-' || c == b'_' {
1917                self.i += 1;
1918            } else {
1919                break;
1920            }
1921        }
1922        std::str::from_utf8(&self.s[start..self.i])
1923            .unwrap_or_default()
1924            .to_string()
1925    }
1926
1927    fn skip_ws(&mut self) {
1928        while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
1929            self.i += 1;
1930        }
1931    }
1932
1933    /// Skip whitespace and `<!-- comments -->`.
1934    fn skip_trivia(&mut self) {
1935        loop {
1936            self.skip_ws();
1937            if self.starts_with("<!--") {
1938                if let Some(end) = self.find_from(self.i, "-->") {
1939                    self.i = end + 3;
1940                    continue;
1941                }
1942            }
1943            break;
1944        }
1945    }
1946
1947    fn peek(&self) -> Option<u8> {
1948        self.s.get(self.i).copied()
1949    }
1950    fn starts_with(&self, p: &str) -> bool {
1951        self.s[self.i..].starts_with(p.as_bytes())
1952    }
1953    fn find_from(&self, from: usize, p: &str) -> Option<usize> {
1954        self.s[from..]
1955            .windows(p.len())
1956            .position(|w| w == p.as_bytes())
1957            .map(|k| from + k)
1958    }
1959    fn expect(&mut self, c: char) -> Result<(), String> {
1960        if self.peek() == Some(c as u8) {
1961            self.i += 1;
1962            Ok(())
1963        } else {
1964            Err(format!("expected '{c}' at byte {}", self.i))
1965        }
1966    }
1967}
1968
1969#[cfg(test)]
1970mod tests {
1971    use super::*;
1972
1973    #[test]
1974    fn models_stamp_with_transform_and_recursion_is_bounded() {
1975        let m = compile(
1976            r#"<world id="w" title="W">
1977                 <model name="lantern">
1978                   <cube id="base" at="0 0.5 0" scale="1 1 1"/>
1979                   <cube id="top" at="0 1.25 0" scale="0.5 0.5 0.5"/>
1980                 </model>
1981                 <use model="lantern" at="10 0 0"/>
1982                 <use model="lantern" at="0 0 -10" yaw="90" scale="2"/>
1983               </world>"#,
1984        )
1985        .unwrap();
1986        // Two stamps × two parts; the definition itself emitted nothing.
1987        let parts: Vec<_> = m
1988            .placements
1989            .iter()
1990            .filter(|p| p.name == "base" || p.name == "top")
1991            .collect();
1992        assert_eq!(parts.len(), 4);
1993        // Stamp 1: plain translation.
1994        let b1 = m
1995            .placements
1996            .iter()
1997            .find(|p| p.name == "base" && p.position[0] > 5.0)
1998            .unwrap();
1999        assert_eq!(b1.position, [10.0, 0.5, 10.0 * 0.0]);
2000        // Stamp 2: scaled ×2 (top sits at 2×1.25 above) and yawed 90°.
2001        let t2 = m
2002            .placements
2003            .iter()
2004            .find(|p| p.name == "top" && p.position[2] < -5.0)
2005            .expect("second stamp's top");
2006        assert!(
2007            (t2.position[1] - 2.5).abs() < 1e-4,
2008            "scaled height: {:?}",
2009            t2.position
2010        );
2011        assert_eq!(t2.scale, [1.0, 1.0, 1.0], "0.5 × 2");
2012        // Unknown model and self-use both fail loudly.
2013        assert!(
2014            compile(r#"<world id="w" title="W"><use model="ghost" at="0 0 0"/></world>"#).is_err()
2015        );
2016        assert!(compile(
2017            r#"<world id="w" title="W">
2018                 <model name="ouro"><use model="ouro" at="0 0 0"/></model>
2019                 <use model="ouro" at="0 0 0"/>
2020               </world>"#
2021        )
2022        .is_err());
2023    }
2024
2025    #[test]
2026    fn ring_places_n_facing_the_centre_and_row_interpolates() {
2027        let m = compile(
2028            r#"<world id="w" title="W">
2029                 <ring n="4" r="10">
2030                   <quad id="board" at="0 1.5 0" scale="2 1 1"/>
2031                 </ring>
2032                 <row n="3" from="-4 0 5" to="4 0 5">
2033                   <cube id="post" at="0 1 0" scale="0.2 2 0.2"/>
2034                 </row>
2035               </world>"#,
2036        )
2037        .unwrap();
2038        let boards: Vec<_> = m.placements.iter().filter(|p| p.name == "board").collect();
2039        assert_eq!(boards.len(), 4);
2040        for b in &boards {
2041            let r = (b.position[0].powi(2) + b.position[2].powi(2)).sqrt();
2042            assert!((r - 10.0).abs() < 1e-3, "on the circle: {:?}", b.position);
2043            // Facing in: the quad's +Z normal (rotated) points at the centre.
2044            let n = quat_rotate(b.rotation, [0.0, 0.0, 1.0]);
2045            let toward = [-b.position[0] / r, 0.0, -b.position[2] / r];
2046            let dot = n[0] * toward[0] + n[2] * toward[2];
2047            assert!(
2048                dot > 0.999,
2049                "board faces centre (dot {dot}) at {:?}",
2050                b.position
2051            );
2052        }
2053        let posts: Vec<_> = m.placements.iter().filter(|p| p.name == "post").collect();
2054        assert_eq!(posts.len(), 3);
2055        let xs: Vec<f32> = posts.iter().map(|p| p.position[0]).collect();
2056        assert!(
2057            xs.contains(&-4.0) && xs.contains(&0.0) && xs.contains(&4.0),
2058            "{xs:?}"
2059        );
2060    }
2061
2062    #[test]
2063    fn lamp_is_geometry_plus_light_in_one_tag() {
2064        let m = compile(r#"<world id="w" title="W"><lamp at="3 0 -2" h="2.0"/></world>"#).unwrap();
2065        let head = m
2066            .placements
2067            .iter()
2068            .find(|p| p.kind.as_deref() == Some("lamp-head"))
2069            .unwrap();
2070        let l = head.light.as_ref().expect("the lamp lights");
2071        assert!(l.intensity > 0.0);
2072        assert!(
2073            head.position[1] > 2.0,
2074            "head atop the post: {:?}",
2075            head.position
2076        );
2077        assert!(m
2078            .placements
2079            .iter()
2080            .any(|p| p.kind.as_deref() == Some("lamp-post")));
2081    }
2082
2083    #[test]
2084    fn presence_attr_is_the_whole_setup() {
2085        // The default: people off.
2086        let m = compile(r#"<world id="w" title="W"></world>"#).unwrap();
2087        assert!(m.presence.is_none());
2088        let m = compile(r#"<world id="w" title="W" presence="none"></world>"#).unwrap();
2089        assert!(m.presence.is_none());
2090        // One word: p2p.
2091        let m = compile(r#"<world id="w" title="W" presence="p2p"></world>"#).unwrap();
2092        let p = m.presence.unwrap();
2093        assert_eq!(p.mode.as_deref(), Some("p2p"));
2094        assert!(p.relays.is_empty());
2095        // One address (plus a fallback): hosted.
2096        let m = compile(
2097            r#"<world id="w" title="W" presence="wss://a.example/thread/w, wss://b.example/thread/w"></world>"#,
2098        )
2099        .unwrap();
2100        let p = m.presence.unwrap();
2101        assert_eq!(p.mode.as_deref(), Some("relay"));
2102        assert_eq!(p.relays.len(), 2);
2103        // A typo is a compile error, never a silently-solo world.
2104        assert!(compile(r#"<world id="w" title="W" presence="p2"></world>"#).is_err());
2105    }
2106
2107    #[test]
2108    fn compiles_a_small_world() {
2109        let src = r##"
2110            <world id="grove" title="The Grove" sky="0.1 0.1 0.2 / 0.5 0.3 0.2">
2111              <cube class="harvestable" at="3 0 -2" scale="1 3 1"/>
2112              <cube at="0 0 -5">
2113                <sphere at="0 1 0"/>
2114              </cube>
2115              <portal to="thread://market" at="0 0 -8" label="Market"/>
2116            </world>
2117            <style>
2118              .harvestable { interaction: "Chop" }
2119            </style>
2120        "##;
2121        let m = compile(src).expect("compiles");
2122        assert_eq!(m.world.id, "grove");
2123        assert_eq!(m.world.title, "The Grove");
2124        assert!(m.environment.sky.is_some());
2125        // Two top-level cubes; the sphere is nested under the second cube.
2126        assert_eq!(m.placements.len(), 2);
2127        let nested = &m.placements[1];
2128        assert_eq!(nested.children.len(), 1);
2129        assert_eq!(nested.children[0].kind.as_deref(), Some("sphere"));
2130        // The harvestable cube got its class; the cascade attaches the interaction.
2131        let harvest = m
2132            .placements
2133            .iter()
2134            .find(|p| p.class.iter().any(|c| c == "harvestable"))
2135            .unwrap();
2136        assert_eq!(m.computed_interaction(harvest).unwrap().label, "Chop");
2137        // One portal, prefabs synthesized for the used builtins (cube + sphere).
2138        assert_eq!(m.portals.len(), 1);
2139        assert_eq!(m.prefabs.len(), 2);
2140        // The whole thing is a conformant manifest.
2141        assert!(m.validate().is_ok());
2142    }
2143
2144    #[test]
2145    fn self_closing_and_valued_attrs_parse() {
2146        let m = compile(r#"<world id="w" title="W"><cube at="0 0 0"/></world>"#).unwrap();
2147        assert_eq!(m.placements.len(), 1);
2148        assert_eq!(m.placements[0].position, [0.0, 0.0, 0.0]);
2149    }
2150
2151    #[test]
2152    fn missing_world_is_an_error() {
2153        assert!(compile(r#"<cube at="0 0 0"/>"#).is_err());
2154    }
2155
2156    #[test]
2157    fn cascade_mesh_synthesizes_prefab_and_asset() {
2158        // `tree { mesh: "…" }` gives an arbitrary <tree> tag its glTF — the
2159        // `tree { mesh: tree.glb }` vision. cube falls back to its builtin.
2160        let m = compile(
2161            r#"
2162            <world id="w" title="W">
2163              <tree at="0 0 0"/>
2164              <tree at="5 0 0"/>
2165              <cube at="1 0 0"/>
2166            </world>
2167            <style>
2168              tree { mesh: "trees/oak.glb" }
2169            </style>
2170        "#,
2171        )
2172        .unwrap();
2173        // One prefab for tree (asset) + one for cube (builtin), deduped across the
2174        // two trees; one assets[] entry for the glTF url.
2175        assert_eq!(m.prefabs.len(), 2);
2176        assert_eq!(m.assets.len(), 1);
2177        assert_eq!(m.assets[0].uri, "trees/oak.glb");
2178        let tree = m
2179            .placements
2180            .iter()
2181            .find(|p| p.kind.as_deref() == Some("tree"))
2182            .unwrap();
2183        let prefab = m.prefabs.iter().find(|p| p.id == tree.prefab).unwrap();
2184        assert_eq!(prefab.mesh.asset.as_deref(), Some("mesh-0"));
2185        assert!(prefab.mesh.builtin.is_none());
2186    }
2187
2188    #[test]
2189    fn unknown_tag_without_a_mesh_rule_errors() {
2190        let err = compile(r#"<world id="w" title="W"><dragon at="0 0 0"/></world>"#).unwrap_err();
2191        assert!(err.contains("no mesh for <dragon>"), "got: {err}");
2192    }
2193
2194    #[test]
2195    fn cascade_color_makes_a_distinct_material() {
2196        let m = compile(
2197            r#"
2198            <world id="w" title="W">
2199              <cube class="gold" at="0 0 0"/>
2200              <cube at="1 0 0"/>
2201            </world>
2202            <style>
2203              .gold { color: yellow; metallic: 1 }
2204            </style>
2205        "#,
2206        )
2207        .unwrap();
2208        // Same mesh (cube), but the gold one has a material → two distinct prefabs.
2209        assert_eq!(m.prefabs.len(), 2);
2210        let gold = m
2211            .placements
2212            .iter()
2213            .find(|p| p.class.iter().any(|c| c == "gold"))
2214            .unwrap();
2215        let mat = m
2216            .prefabs
2217            .iter()
2218            .find(|p| p.id == gold.prefab)
2219            .unwrap()
2220            .material
2221            .as_ref()
2222            .expect("gold cube has a material");
2223        assert_eq!(mat.base_color, [0.9, 0.85, 0.2, 1.0]); // named "yellow"
2224        assert_eq!(mat.metallic, 1.0);
2225        // The plain cube shares the mesh but has no material block.
2226        let plain = m.placements.iter().find(|p| p.class.is_empty()).unwrap();
2227        assert!(m
2228            .prefabs
2229            .iter()
2230            .find(|p| p.id == plain.prefab)
2231            .unwrap()
2232            .material
2233            .is_none());
2234    }
2235
2236    #[test]
2237    fn spawn_element_and_default_spawn() {
2238        // An authored <spawn> lands verbatim (yaw degrees → radians)…
2239        let m = compile(
2240            r#"<world id="w" title="W"><spawn at="0 0 8" name="gate" yaw="180"/><cube/></world>"#,
2241        )
2242        .unwrap();
2243        assert_eq!(m.spawns.len(), 1);
2244        assert_eq!(m.spawns[0].name, "gate");
2245        assert_eq!(m.spawns[0].position, [0.0, 0.0, 8.0]);
2246        assert!((m.spawns[0].yaw - std::f32::consts::PI).abs() < 1e-5);
2247        // …and a world with none still gets a default arrival point.
2248        let d = compile(r#"<world id="w" title="W"><cube/></world>"#).unwrap();
2249        assert_eq!(d.spawns.len(), 1);
2250        assert_eq!(d.spawns[0].name, "entry");
2251    }
2252
2253    #[test]
2254    fn rot_attr_becomes_a_quaternion() {
2255        let m = compile(r#"<world id="w" title="W"><cube rot="0 90 0"/></world>"#).unwrap();
2256        let q = m.placements[0].rotation;
2257        let s45 = (std::f32::consts::FRAC_PI_4).sin();
2258        assert!(
2259            (q[1] - s45).abs() < 1e-5 && (q[3] - s45).abs() < 1e-5,
2260            "got {q:?}"
2261        );
2262        assert!(q[0].abs() < 1e-6 && q[2].abs() < 1e-6);
2263        // Unit length.
2264        let len: f32 = q.iter().map(|c| c * c).sum::<f32>().sqrt();
2265        assert!((len - 1.0).abs() < 1e-5);
2266    }
2267
2268    #[test]
2269    fn codex_url_and_data_attrs_flow_to_the_placement() {
2270        let m = compile(
2271            r#"<world id="w" title="W">
2272                 <cube codex="the-thread" url="https://example.com"
2273                       data-price="250" data-item="20100001" data-featured="true" data-note="hi"/>
2274               </world>"#,
2275        )
2276        .unwrap();
2277        let p = &m.placements[0];
2278        assert_eq!(p.codex.as_deref(), Some("the-thread"));
2279        assert_eq!(p.data["url"], "https://example.com");
2280        assert_eq!(p.data["price"], 250);
2281        assert_eq!(p.data["item"], 20100001);
2282        assert_eq!(p.data["featured"], true);
2283        assert_eq!(p.data["note"], "hi");
2284    }
2285
2286    #[test]
2287    fn world_rules_year_and_description_parse() {
2288        let m = compile(
2289            r#"<world id="w" title="W" description="A test" year="-2400" rules="survival, gathering"><cube/></world>"#,
2290        )
2291        .unwrap();
2292        assert_eq!(m.world.description, "A test");
2293        assert_eq!(m.environment.year, Some(-2400));
2294        assert!(m.environment.rules.survival);
2295        assert!(m.environment.rules.gathering);
2296        assert!(!m.environment.rules.combat);
2297        // A typo'd rule is an error, not a silent no-op.
2298        assert!(compile(r#"<world id="w" title="W" rules="surival"><cube/></world>"#).is_err());
2299    }
2300
2301    #[test]
2302    fn interaction_longhands_build_full_effects() {
2303        let m = compile(
2304            r#"
2305            <world id="w" title="W"><tree class="harvestable"/></world>
2306            <style>
2307              tree { mesh: "oak.glb" }
2308              .harvestable {
2309                interaction: "Chop";
2310                interaction-hits: 3;
2311                interaction-gives: 20100001 3;
2312                interaction-message: "You chopped the old oak.";
2313                interaction-despawns: true;
2314              }
2315            </style>
2316        "#,
2317        )
2318        .unwrap();
2319        let i = m.computed_interaction(&m.placements[0]).expect("cascades");
2320        assert_eq!(i.label, "Chop");
2321        assert_eq!(i.hits, 3);
2322        assert_eq!(
2323            i.effects,
2324            vec![
2325                InteractionEffect::GiveItem {
2326                    item: StructuredId(20100001),
2327                    count: 3
2328                },
2329                InteractionEffect::Message("You chopped the old oak.".into()),
2330                InteractionEffect::Despawn,
2331            ]
2332        );
2333        // Longhands without a verb are caught at compile time.
2334        let err = compile(
2335            r#"<world id="w" title="W"><cube/></world><style>cube { interaction-hits: 2 }</style>"#,
2336        )
2337        .unwrap_err();
2338        assert!(err.contains("no `interaction:` verb"), "got: {err}");
2339    }
2340
2341    #[test]
2342    fn sky_presets_and_explicit_sun_parse() {
2343        // A named preset gives a full atmosphere, sun included.
2344        let m = compile(r#"<world id="w" title="W" sky="dusk"><cube/></world>"#).unwrap();
2345        let sky = m.environment.sky.expect("preset resolves");
2346        assert!(sky.sun_dir.is_some(), "presets set a sun");
2347        // Explicit gradients still work, now with an optional sun segment.
2348        let m = compile(
2349            r#"<world id="w" title="W" sky="0.1 0.1 0.2 / 0.5 0.3 0.2 / 0.7 0.2 0.3"><cube/></world>"#,
2350        )
2351        .unwrap();
2352        let sky = m.environment.sky.unwrap();
2353        assert_eq!(sky.zenith, [0.1, 0.1, 0.2]);
2354        assert_eq!(sky.sun_dir, Some([0.7, 0.2, 0.3]));
2355        // Two-segment form keeps its old meaning (no sun declared).
2356        let m =
2357            compile(r#"<world id="w" title="W" sky="0.1 0.1 0.2 / 0.5 0.3 0.2"><cube/></world>"#)
2358                .unwrap();
2359        assert_eq!(m.environment.sky.unwrap().sun_dir, None);
2360    }
2361
2362    #[test]
2363    fn room_compiles_to_floor_walls_columns_with_gates() {
2364        let m = compile(
2365            r#"<world id="w" title="W">
2366                 <room at="0 0 0" r="10" h="3" columns="12" gates="180 0" gate-width="15">
2367                   <cube id="exhibit" at="0 1 0"/>
2368                 </room>
2369               </world>"#,
2370        )
2371        .unwrap();
2372        let walls = m
2373            .placements
2374            .iter()
2375            .filter(|p| p.kind.as_deref() == Some("room-wall"))
2376            .count();
2377        let cols = m
2378            .placements
2379            .iter()
2380            .filter(|p| p.kind.as_deref() == Some("room-column"))
2381            .count();
2382        // 12 segments minus one gate at 180 and one at 0 (15° half-width covers
2383        // the two segments straddling each gate azimuth = 12 − 4).
2384        assert_eq!(cols, 12);
2385        assert!(walls < 12 && walls >= 8, "gates opened: {walls} walls");
2386        assert_eq!(
2387            m.placements
2388                .iter()
2389                .filter(|p| p.kind.as_deref() == Some("room-floor"))
2390                .count(),
2391            1
2392        );
2393        // The child rode along in the room's frame (translated by its centre).
2394        let ex = m.placements.iter().find(|p| p.name == "exhibit").unwrap();
2395        assert_eq!(ex.position, [0.0, 1.0, 0.0]);
2396        assert!(m.validate().is_ok());
2397    }
2398
2399    #[test]
2400    fn room_children_translate_with_the_room() {
2401        let m = compile(
2402            r#"<world id="w" title="W">
2403                 <room at="10 0 -20" r="8">
2404                   <cube id="pedestal" at="0 0.5 0"/>
2405                   <portal to="thread://elsewhere" at="0 1.4 -6" label="On"/>
2406                 </room>
2407               </world>"#,
2408        )
2409        .unwrap();
2410        let p = m.placements.iter().find(|p| p.name == "pedestal").unwrap();
2411        assert_eq!(p.position, [10.0, 0.5, -20.0]);
2412        assert_eq!(m.portals[0].position, [10.0, 1.4, -26.0]);
2413    }
2414
2415    #[test]
2416    fn wall_element_spans_two_points() {
2417        let m = compile(r#"<world id="w" title="W"><wall from="-5 0" to="5 0" h="2.5"/></world>"#)
2418            .unwrap();
2419        let w = m
2420            .placements
2421            .iter()
2422            .find(|p| p.kind.as_deref() == Some("wall"))
2423            .unwrap();
2424        assert_eq!(w.position, [0.0, 1.25, 0.0]);
2425        assert!((w.scale[0] - 10.0).abs() < 1e-4, "length {:?}", w.scale);
2426        assert_eq!(w.scale[1], 2.5);
2427    }
2428
2429    #[test]
2430    fn text_attrs_carry_inline_hyperlinks() {
2431        let m = compile(
2432            r#"<world id="w" title="W">
2433                 <quad at="0 1 0" text="He founded [[thread://wiki.pixygon.io/wiki/kyuss|Kyuss]] and later [[thread://wiki.pixygon.io/wiki/qotsa]]."/>
2434               </world>"#,
2435        )
2436        .unwrap();
2437        let t = m.placements[0].text.as_ref().unwrap();
2438        assert_eq!(
2439            t.content,
2440            "He founded Kyuss and later thread://wiki.pixygon.io/wiki/qotsa."
2441        );
2442        assert_eq!(t.links.len(), 2);
2443        assert_eq!(t.links[0].text, "Kyuss");
2444        assert_eq!(t.links[0].to, "thread://wiki.pixygon.io/wiki/kyuss");
2445        assert_eq!(t.links[1].to, "thread://wiki.pixygon.io/wiki/qotsa");
2446    }
2447
2448    #[test]
2449    fn weft_use_binds_a_package_export() {
2450        let m = compile(
2451            r#"<world id="w" title="W">
2452                 <cube id="clock" weft-use="weft-clock.weftpack.json#clock" weft-on="tick"/>
2453               </world>"#,
2454        )
2455        .unwrap();
2456        let b = &m.behaviors[0];
2457        assert!(b.weft.is_none());
2458        assert_eq!(b.weft_export.as_deref(), Some("clock"));
2459        assert_eq!(b.on, vec!["tick".to_string()]);
2460        let aid = b.weft_pack.as_ref().unwrap();
2461        let asset = m.assets.iter().find(|a| &a.id == aid).unwrap();
2462        assert_eq!(asset.uri, "weft-clock.weftpack.json");
2463        assert_eq!(m.placements[0].behavior.as_deref(), Some(b.id.as_str()));
2464    }
2465
2466    #[test]
2467    fn light_attr_and_style_become_emitters() {
2468        let m = compile(
2469            r#"
2470            <world id="w" title="W">
2471              <sphere id="lamp" at="0 2 0" light="1 0.9 0.6 2 12"/>
2472              <sphere class="stage" at="3 2 0"/>
2473              <cube at="1 0 0"/>
2474            </world>
2475            <style>.stage { light: 0.5 0.85 1 }</style>
2476        "#,
2477        )
2478        .unwrap();
2479        let lamp = m.placements.iter().find(|p| p.name == "lamp").unwrap();
2480        let e = lamp.light.as_ref().expect("attr light");
2481        assert_eq!(e.color, [1.0, 0.9, 0.6]);
2482        assert_eq!(e.intensity, 2.0);
2483        assert_eq!(e.range, 12.0);
2484        let stage = m
2485            .placements
2486            .iter()
2487            .find(|p| p.class.iter().any(|c| c == "stage"))
2488            .unwrap();
2489        assert_eq!(stage.light.as_ref().unwrap().color, [0.5, 0.85, 1.0]);
2490        assert!(m
2491            .placements
2492            .iter()
2493            .find(|p| p.kind.as_deref() == Some("cube"))
2494            .unwrap()
2495            .light
2496            .is_none());
2497    }
2498
2499    #[test]
2500    fn emissive_cascades_into_the_material() {
2501        let m = compile(
2502            r#"
2503            <world id="w" title="W"><cube class="lamp"/><cube/></world>
2504            <style>.lamp { color: 1 0.9 0.6; emissive: true }</style>
2505        "#,
2506        )
2507        .unwrap();
2508        let lamp = m
2509            .placements
2510            .iter()
2511            .find(|p| p.class.iter().any(|c| c == "lamp"))
2512            .unwrap();
2513        let mat = m
2514            .prefabs
2515            .iter()
2516            .find(|p| p.id == lamp.prefab)
2517            .unwrap()
2518            .material
2519            .as_ref()
2520            .unwrap();
2521        assert_eq!(mat.emissive, 1.0);
2522        // Emissive alone distinguishes the prefab from the plain cube.
2523        assert_eq!(m.prefabs.len(), 2);
2524    }
2525}