1use 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
69const BUILTINS: &[&str] = &["cube", "sphere", "cylinder", "capsule", "plane", "quad"];
72
73#[derive(Debug)]
75struct Element {
76 tag: String,
77 attrs: BTreeMap<String, String>,
78 children: Vec<Element>,
79}
80
81pub fn compile(src: &str) -> Result<WorldManifest, String> {
83 let (markup, style_body) = extract_style(src);
85 let decls = parse_style(&style_body)?;
86 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 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; }
179 emit(child, &mut manifest, &decls, &mut looks, &models, 0)?;
180 }
181
182 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
196fn 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 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 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 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 let before = manifest.placements.len();
312 emit(c, manifest, decls, looks, models, depth + 1)?;
313 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
366fn 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
400fn 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
435fn 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#[allow(clippy::too_many_arguments)]
446fn 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
545fn 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 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
626fn 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 }
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
680fn 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
717fn 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 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
779fn 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
829fn 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
907fn quat_rotate(q: [f32; 4], v: [f32; 3]) -> [f32; 3] {
909 let [qx, qy, qz, qw] = q;
910 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
953fn 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 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 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
1111fn 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
1172fn 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
1192fn 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
1210fn 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
1277struct StyleDecl {
1280 select: String,
1281 props: BTreeMap<String, String>,
1282}
1283
1284fn 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 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
1334fn 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
1353fn 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
1367fn resolve_prop(el: &Element, decls: &[StyleDecl], prop: &str) -> Option<String> {
1370 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
1387struct Look {
1389 mesh: String,
1390 base_color: [f32; 4],
1391 metallic: f32,
1392 roughness: f32,
1393 emissive: f32,
1395 texture: Option<String>,
1397}
1398
1399impl Look {
1400 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
1416fn parse_emissive(v: &str) -> f32 {
1418 match v {
1419 "true" => 1.0,
1420 _ => v.parse().unwrap_or(0.0),
1421 }
1422}
1423
1424fn 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
1451struct LookRegistry {
1455 prefabs: BTreeMap<String, StructuredId>,
1456 assets: BTreeMap<String, String>,
1457 shapes: BTreeMap<String, (crate::shape::Shape, Option<u32>)>,
1459 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 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 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
1559fn weft_binding(el: &Element, manifest: &mut WorldManifest) -> Option<String> {
1564 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 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
1614fn 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
1630fn 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
1644fn 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
1652fn 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
1664fn 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
1678fn 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
1690fn 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
1733fn 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
1752fn 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
1766fn 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
1797struct 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 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 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())); }
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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(m.portals.len(), 1);
2139 assert_eq!(m.prefabs.len(), 2);
2140 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 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 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 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]); assert_eq!(mat.metallic, 1.0);
2225 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(m.prefabs.len(), 2);
2524 }
2525}