use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::descriptor::{Descriptor, Dir as ClearDir, Mount, DecalHost, Subgrid};
use crate::library::Library;
use crate::map::{Location, Map, Placed};
use crate::placement::ir::Dir;
use crate::wfc::{E, N, S, W};
pub const MAX_COMPOSITION_DEPTH: u32 = 8;
pub const MAX_RESOLVED_MEMBERS: usize = 256;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Composition {
pub id: String,
#[serde(default)]
pub envelope: Envelope,
pub members: Vec<Member>,
#[serde(default)]
pub locations: Vec<Location>,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Envelope {
#[default]
Anchored,
Bounded { size: (f32, f32, f32) },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Member {
pub id: String,
pub body: Body,
pub at: (f32, f32),
#[serde(default)]
pub yaw: f32,
#[serde(default)]
pub lift: f32,
#[serde(default, skip_serializing_if = "paint_is_zero")]
pub paint: i8,
#[serde(default)]
pub of_fingerprint: Option<u64>,
#[serde(default)]
pub note: Option<String>,
}
fn paint_is_zero(v: &i8) -> bool {
*v == 0
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(clippy::large_enum_variant)]
pub enum Body {
Descriptor {
id: String,
#[serde(default)]
tip: (u8, u8),
#[serde(default)]
on: Option<String>,
#[serde(default)]
patch: Option<Descriptor>,
},
Composition { id: String },
Slot {
accepts: String,
},
}
impl Body {
pub fn target(&self) -> &str {
match self {
Body::Descriptor { id, .. } | Body::Composition { id } => id,
Body::Slot { accepts } => accepts,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct OpenSlot {
pub id: String,
pub accepts: String,
pub at: (f32, f32),
pub yaw: f32,
pub lift: f32,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Stamped {
pub id: String,
pub of: String,
pub at: (f32, f32),
#[serde(default)]
pub yaw: f32,
#[serde(default)]
pub overrides: Vec<Override>,
#[serde(default)]
pub of_fingerprint: Option<u64>,
#[serde(default)]
pub owned: bool,
#[serde(default)]
pub owned_because: Option<String>,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Override {
pub member: String,
pub patch: Descriptor,
pub because: String,
}
pub fn agrees(a: &Interface, b: &Interface, dir: Dir) -> bool {
let opposite = match dir {
crate::wfc::N => crate::wfc::S,
crate::wfc::E => crate::wfc::W,
crate::wfc::S => crate::wfc::N,
_ => crate::wfc::E,
};
let (Some(ours), Some(theirs)) = (a.faces.get(dir), b.faces.get(opposite)) else {
return false;
};
for x in ours {
for y in theirs {
let over_y = (x.y.0.max(y.y.0), x.y.1.min(y.y.1));
let over_lat = (x.lat.0.max(y.lat.0), x.lat.1.min(y.lat.1));
if over_y.1 - over_y.0 <= SEAM_EPSILON || over_lat.1 - over_lat.0 <= SEAM_EPSILON {
continue;
}
if x.token != y.token {
return false;
}
}
}
true
}
pub const SEAM_EPSILON: f32 = 1e-4;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Expansion {
pub placements: Vec<Placed>,
pub locations: Vec<Location>,
pub slots: Vec<OpenSlot>,
pub from: BTreeMap<String, (String, String)>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct InterfaceFault {
pub dir: Dir,
pub a: String,
pub b: String,
pub a_token: Option<String>,
pub b_token: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Band {
pub y: (f32, f32),
pub lat: (f32, f32),
pub token: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Interface {
pub faces: [Vec<Band>; 4],
pub faults: Vec<InterfaceFault>,
}
impl Interface {
pub fn is_clean(&self) -> bool {
self.faults.is_empty()
}
}
pub fn rotate_xz(local: (f32, f32), yaw_deg: f32) -> (f32, f32) {
let (s, c) = yaw_deg.to_radians().sin_cos();
(local.0 * c + local.1 * s, -local.0 * s + local.1 * c)
}
pub fn add_yaw(a: f32, b: f32) -> f32 {
(a + b).rem_euclid(360.0)
}
#[derive(Clone, Debug)]
struct Flat {
path: String,
descriptor: String,
at: (f32, f32),
yaw: f32,
lift: f32,
paint: i8,
tip: (u8, u8),
on: Option<String>,
patch: Option<Descriptor>,
note: Option<String>,
}
fn find<'a>(compositions: &'a [Composition], id: &str, who: &str) -> Result<&'a Composition, String> {
compositions.iter().find(|c| c.id == id).ok_or_else(|| {
format!("composition: `{who}` refers to composition `{id}`, which does not exist")
})
}
type Flattened = (Vec<Flat>, Vec<Location>, Vec<OpenSlot>);
fn flatten(
comp: &Composition,
compositions: &[Composition],
overrides: &[Override],
stack: &mut Vec<String>,
depth: u32,
) -> Result<Flattened, String> {
if stack.contains(&comp.id) {
stack.push(comp.id.clone());
return Err(format!(
"composition: `{}` contains itself — {}. A group inside itself has no members to end at.",
comp.id,
stack.join(" → ")
));
}
if depth > MAX_COMPOSITION_DEPTH {
return Err(format!(
"composition: `{}` nests deeper than {MAX_COMPOSITION_DEPTH} — {}. Flatten one of these \
levels rather than raising the cap; every level multiplies what one stamp costs.",
comp.id,
stack.join(" → ")
));
}
stack.push(comp.id.clone());
let mut flats: Vec<Flat> = Vec::new();
let mut locs: Vec<Location> = Vec::new();
let mut slots: Vec<OpenSlot> = Vec::new();
for m in &comp.members {
match &m.body {
Body::Descriptor {
id,
tip,
on,
patch,
} => {
let over = overrides.iter().find(|o| o.member == m.id);
let merged = match (patch, over) {
(Some(p), Some(o)) => Some(p.patched_with(&o.patch)),
(Some(p), None) => Some(p.clone()),
(None, Some(o)) => Some(o.patch.clone()),
(None, None) => None,
};
flats.push(Flat {
path: m.id.clone(),
descriptor: id.clone(),
at: m.at,
yaw: m.yaw,
lift: m.lift,
paint: m.paint,
tip: *tip,
on: on.clone(),
patch: merged,
note: m.note.clone(),
});
}
Body::Slot { accepts } => {
slots.push(OpenSlot {
id: m.id.clone(),
accepts: accepts.clone(),
at: m.at,
yaw: m.yaw,
lift: m.lift,
});
}
Body::Composition { id } => {
let inner = find(compositions, id, &comp.id)?;
let (inner_flats, inner_locs, inner_slots) =
flatten(inner, compositions, &[], stack, depth + 1)?;
for f in inner_flats {
let at = rotate_xz(f.at, m.yaw);
flats.push(Flat {
path: format!("{}/{}", m.id, f.path),
descriptor: f.descriptor,
at: (m.at.0 + at.0, m.at.1 + at.1),
yaw: add_yaw(f.yaw, m.yaw),
lift: m.lift + f.lift,
paint: m.paint.saturating_add(f.paint),
tip: f.tip,
on: f.on.map(|o| format!("{}/{}", m.id, o)),
patch: f.patch,
note: f.note,
});
}
for l in inner_locs {
locs.push(Location {
id: format!("{}/{}", m.id, l.id),
props: l.props.iter().map(|p| format!("{}/{}", m.id, p)).collect(),
interactions: l.interactions,
note: l.note,
});
}
for s in inner_slots {
let at = rotate_xz(s.at, m.yaw);
slots.push(OpenSlot {
id: format!("{}/{}", m.id, s.id),
accepts: s.accepts,
at: (m.at.0 + at.0, m.at.1 + at.1),
yaw: add_yaw(s.yaw, m.yaw),
lift: m.lift + s.lift,
});
}
}
}
}
locs.extend(comp.locations.iter().cloned());
stack.pop();
if flats.len() > MAX_RESOLVED_MEMBERS {
return Err(format!(
"composition: `{}` expands to {} rows, over the {MAX_RESOLVED_MEMBERS} a stamp may \
carry. Split it into named groups rather than raising the cap — the editor's ghost draws \
the real contents of every stamp.",
comp.id,
flats.len()
));
}
Ok((flats, locs, slots))
}
impl Composition {
pub fn validate_shape(&self) -> Result<(), String> {
if !crate::naming::is_id(&self.id) {
return Err(format!(
"composition: `{}` is not a usable id. Ids are snake_case, with `/` separating a kit \
from a piece — `ward/nurse_station`.",
self.id
));
}
if self.members.is_empty() {
return Err(format!(
"composition: `{}` has no members. An empty composition stamps nothing, which looks \
exactly like a stamp that failed.",
self.id
));
}
if let Envelope::Bounded { size } = self.envelope {
for (axis, v) in [("x", size.0), ("y", size.1), ("z", size.2)] {
if !(v.is_finite() && v > 0.0) {
return Err(format!(
"composition: `{}` has envelope {axis} of {v}. A bounded group has to enclose \
something, and its edge tokens are read off that boundary.",
self.id
));
}
}
}
let mut seen: Vec<&str> = Vec::with_capacity(self.members.len());
for m in &self.members {
if !crate::naming::is_id(&m.id) {
return Err(format!(
"composition: `{}` has a member named `{}`, which is not a usable id",
self.id, m.id
));
}
if seen.contains(&m.id.as_str()) {
return Err(format!(
"composition: `{}` uses member id `{}` twice — an override names a member by id, \
so a duplicate makes it ambiguous which one it means",
self.id, m.id
));
}
seen.push(&m.id);
if !m.at.0.is_finite() || !m.at.1.is_finite() || !m.yaw.is_finite() || !m.lift.is_finite()
{
return Err(format!(
"composition: `{}` member `{}` has a position that is not a number",
self.id, m.id
));
}
if let (Body::Slot { accepts }, Envelope::Bounded { size }) = (&m.body, &self.envelope) {
let (hx, hz) = (size.0 * 0.5, size.2 * 0.5);
if m.at.0.abs() >= hx || m.at.1.abs() >= hz || m.lift < 0.0 || m.lift >= size.1 {
return Err(format!(
"composition: `{}` slot `{}` accepts `{accepts}` at ({:.3}, {:.3}) lift \
{:.3}, which is on or outside its {:.2} x {:.2} x {:.2} m envelope. A slot \
adds nothing to what the tile presents on its faces, and that is only \
honest while it sits inside them.",
self.id, m.id, m.at.0, m.at.1, m.lift, size.0, size.1, size.2
));
}
}
if let Body::Descriptor { tip, .. } = &m.body
&& (tip.0 > 3 || tip.1 > 3)
{
return Err(format!(
"composition: `{}` member `{}` has tip {tip:?} — quarter turns are 0..=3 per axis",
self.id, m.id
));
}
}
let mut sorted: Vec<&str> = seen.clone();
sorted.sort_unstable();
if sorted != seen {
return Err(format!(
"composition: `{}` lists its members out of order. They are stored sorted by id so \
that one group has one encoding; write them as: {}",
self.id,
sorted.join(", ")
));
}
for m in &self.members {
let Body::Descriptor { on: Some(host), .. } = &m.body else {
continue;
};
if host == &m.id {
return Err(format!(
"composition: `{}` member `{}` rests on itself",
self.id, m.id
));
}
if !seen.contains(&host.as_str()) {
return Err(format!(
"composition: `{}` member `{}` rests on `{host}`, which is not a member of it. A \
host outside the group is written as no host at all — `on: None` asks the map it \
lands in.",
self.id, m.id
));
}
}
self.no_resting_cycles()?;
for l in &self.locations {
if l.props.is_empty() {
return Err(format!(
"composition: `{}` location `{}` governs no props",
self.id, l.id
));
}
for p in &l.props {
if !seen.contains(&p.as_str()) {
return Err(format!(
"composition: `{}` location `{}` references member `{p}`, which does not exist",
self.id, l.id
));
}
}
}
Ok(())
}
fn no_resting_cycles(&self) -> Result<(), String> {
for start in &self.members {
let mut chain: Vec<&str> = vec![start.id.as_str()];
let mut at = start;
while let Body::Descriptor { on: Some(host), .. } = &at.body {
let Some(next) = self.members.iter().find(|m| &m.id == host) else {
break; };
if chain.contains(&next.id.as_str()) {
chain.push(&next.id);
return Err(format!(
"composition: `{}` has a resting cycle — {}. Its height would be its own.",
self.id,
chain.join(" → ")
));
}
chain.push(&next.id);
at = next;
}
}
Ok(())
}
}
pub fn validate(compositions: &[Composition], library: &Library) -> Result<(), String> {
let mut seen: Vec<&str> = Vec::with_capacity(compositions.len());
for c in compositions {
c.validate_shape()?;
if seen.contains(&c.id.as_str()) {
return Err(format!(
"composition: id `{}` is used twice — a stamp names one by id, so a duplicate makes \
it ambiguous which is stamped",
c.id
));
}
seen.push(&c.id);
}
for c in compositions {
for m in &c.members {
match &m.body {
Body::Descriptor { id, .. } => {
if library.get(id).is_none() {
return Err(format!(
"composition: `{}` member `{}` places descriptor `{id}`, which the \
library does not define",
c.id, m.id
));
}
}
Body::Composition { id } => {
find(compositions, id, &c.id)?;
}
Body::Slot { .. } => {}
}
}
}
for c in compositions {
flatten(c, compositions, &[], &mut Vec::new(), 0)?;
}
Ok(())
}
#[derive(Default)]
struct Fp(Vec<u8>);
impl Fp {
fn tag(&mut self, t: u8) -> &mut Self {
self.0.push(t);
self
}
fn f32(&mut self, v: f32) -> &mut Self {
self.0.extend_from_slice(&v.to_bits().to_le_bytes());
self
}
fn u32(&mut self, v: u32) -> &mut Self {
self.0.extend_from_slice(&v.to_le_bytes());
self
}
fn u64(&mut self, v: u64) -> &mut Self {
self.0.extend_from_slice(&v.to_le_bytes());
self
}
fn str(&mut self, s: &str) -> &mut Self {
self.u32(s.len() as u32);
self.0.extend_from_slice(s.as_bytes());
self
}
fn opt_f32(&mut self, v: Option<f32>) -> &mut Self {
match v {
Some(v) => self.tag(1).f32(v),
None => self.tag(0),
}
}
fn opt_str(&mut self, v: Option<&str>) -> &mut Self {
match v {
Some(v) => self.tag(1).str(v),
None => self.tag(0),
}
}
fn finish(&self) -> u64 {
crate::glb::fnv1a(&self.0)
}
}
pub fn descriptor_fingerprint(d: &Descriptor) -> u64 {
let mut f = Fp::default();
f.str(&d.id);
f.opt_f32(d.extent.footprint.map(|x| x.0));
f.opt_f32(d.extent.footprint.map(|x| x.1));
f.opt_f32(d.extent.height);
f.opt_f32(d.align.scale);
f.opt_f32(d.align.stretch_y);
f.opt_f32(d.align.y_offset);
f.opt_f32(d.align.pivot.map(|p| p.0));
f.opt_f32(d.align.pivot.map(|p| p.1));
match d.align.rotate {
Some((x, y, z)) => {
f.tag(1);
f.u32(x as u32).u32(y as u32).u32(z as u32);
}
None => {
f.tag(0);
}
}
match d.align.front {
None => {
f.tag(0);
}
Some(face) => {
f.tag(1).u32(face.dir() as u32);
}
}
match &d.mount {
None => {
f.tag(0);
}
Some(Mount::OnFloor) => {
f.tag(1);
}
Some(Mount::OnWall { height }) => {
f.tag(2).f32(*height);
}
Some(Mount::OnCeiling) => {
f.tag(3);
}
Some(Mount::InOpening { clear }) => {
f.tag(4);
f.opt_f32(clear.map(|c| c.0));
f.opt_f32(clear.map(|c| c.1));
}
Some(Mount::OnSurface { class }) => {
f.tag(5).str(class);
}
Some(Mount::Decal { on }) => {
f.tag(6);
match on {
DecalHost::Floor => f.tag(0),
DecalHost::Ceiling => f.tag(1),
DecalHost::Wall { height } => f.tag(2).f32(*height),
};
}
Some(Mount::Tiled) => {
f.tag(7);
}
Some(Mount::OnFace { class, height }) => {
f.tag(8).str(class).f32(*height);
}
Some(Mount::OnSurfaceOrFloor { class }) => {
f.tag(9).str(class);
}
}
f.u32(d.clearance.len() as u32);
for c in &d.clearance {
let dir = match c.dir {
ClearDir::Front => 0u8,
ClearDir::Back => 1,
ClearDir::Left => 2,
ClearDir::Right => 3,
ClearDir::Around => 4,
};
f.tag(dir).f32(c.dist);
}
f.u32(d.offers.surfaces.len() as u32);
for s in &d.offers.surfaces {
f.str(s);
}
f.u32(d.offers.faces.len() as u32);
for s in &d.offers.faces {
f.str(s);
}
f.u32(d.offers.sockets.len() as u32);
for s in &d.offers.sockets {
f.str(&s.id).opt_str(s.role.as_deref());
f.f32(s.at.0).f32(s.at.1).f32(s.at.2).f32(s.yaw);
}
let mut edges: Vec<(&(u32, u32, u32), &str)> = d
.subgrid
.as_ref()
.map(|g| {
g.cells
.iter()
.filter_map(|c| c.edge.as_deref().map(|e| (&c.at, e)))
.collect()
})
.unwrap_or_default();
edges.sort_unstable();
f.u32(edges.len() as u32);
for (at, e) in edges {
f.u32(at.0).u32(at.1).u32(at.2).str(e);
}
f.finish()
}
pub fn composition_fingerprint(
comp: &Composition,
compositions: &[Composition],
library: &Library,
) -> Result<u64, String> {
fingerprint_inner(comp, compositions, library, &mut Vec::new(), 0)
}
fn fingerprint_inner(
comp: &Composition,
compositions: &[Composition],
library: &Library,
stack: &mut Vec<String>,
depth: u32,
) -> Result<u64, String> {
if stack.contains(&comp.id) || depth > MAX_COMPOSITION_DEPTH {
return Err(format!(
"composition: cannot fingerprint `{}` — it contains itself or nests too deep",
comp.id
));
}
stack.push(comp.id.clone());
let mut f = Fp::default();
f.str(&comp.id);
match comp.envelope {
Envelope::Anchored => {
f.tag(0);
}
Envelope::Bounded { size } => {
f.tag(1).f32(size.0).f32(size.1).f32(size.2);
}
}
f.u32(comp.members.len() as u32);
for m in &comp.members {
f.str(&m.id).f32(m.at.0).f32(m.at.1).f32(m.yaw).f32(m.lift);
f.u32(m.paint as i32 as u32);
f.u64(body_fingerprint(&m.body, compositions, library, stack, depth)?);
}
stack.pop();
Ok(f.finish())
}
pub fn body_fingerprint_of(
body: &Body,
compositions: &[Composition],
library: &Library,
) -> Result<u64, String> {
body_fingerprint(body, compositions, library, &mut Vec::new(), 0)
}
fn body_fingerprint(
body: &Body,
compositions: &[Composition],
library: &Library,
stack: &mut Vec<String>,
depth: u32,
) -> Result<u64, String> {
match body {
Body::Descriptor { id, tip, on, patch } => {
let base = library.get(id).ok_or_else(|| {
format!("composition: cannot fingerprint descriptor `{id}` — the library does not define it")
})?;
let resolved = match patch {
Some(p) => base.patched_with(p),
None => base.clone(),
};
let mut f = Fp::default();
f.u64(descriptor_fingerprint(&resolved));
f.u32(tip.0 as u32).u32(tip.1 as u32);
f.opt_str(on.as_deref());
Ok(f.finish())
}
Body::Composition { id } => {
let inner = find(compositions, id, "a member")?;
fingerprint_inner(inner, compositions, library, stack, depth + 1)
}
Body::Slot { accepts } => {
let mut f = Fp::default();
f.tag(SLOT_TAG).str(accepts);
Ok(f.finish())
}
}
}
const SLOT_TAG: u8 = b'S';
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Freshness {
Fresh,
Stale,
Unrecorded,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Stale {
pub composition: String,
pub member: String,
pub freshness: Freshness,
pub recorded: Option<u64>,
pub measured: u64,
}
pub fn stale_members(
comp: &Composition,
compositions: &[Composition],
library: &Library,
) -> Result<Vec<Stale>, String> {
let mut out = Vec::new();
for m in &comp.members {
let measured = body_fingerprint_of(&m.body, compositions, library)?;
let freshness = match m.of_fingerprint {
None => Freshness::Unrecorded,
Some(r) if r == measured => Freshness::Fresh,
Some(_) => Freshness::Stale,
};
if freshness != Freshness::Fresh {
out.push(Stale {
composition: comp.id.clone(),
member: m.id.clone(),
freshness,
recorded: m.of_fingerprint,
measured,
});
}
}
Ok(out)
}
pub fn stamped_id(stamp: &str, member: &str) -> String {
format!("{stamp}/{member}")
}
pub fn split_stamped_id(id: &str) -> Option<(&str, &str)> {
let (stamp, member) = id.split_once('/')?;
if stamp.is_empty() || member.is_empty() {
return None;
}
Some((stamp, member))
}
pub fn expand(
map: &Map,
stamps: &[Stamped],
compositions: &[Composition],
library: &Library,
) -> Result<Expansion, String> {
let mut out = Expansion::default();
if stamps.is_empty() {
return Ok(out);
}
let hand_placed: BTreeSet<&str> = map.placements.iter().map(|p| p.id.as_str()).collect();
let mut order: Vec<&Stamped> = stamps.iter().collect();
order.sort_by(|a, b| a.id.cmp(&b.id));
let mut seen: BTreeSet<&str> = BTreeSet::new();
for s in &order {
if !seen.insert(s.id.as_str()) {
return Err(format!(
"map: stamp id `{}` is used twice — the rows it expands to are named after it, so a \
duplicate would produce two rows with one id",
s.id
));
}
}
let mut working = map.clone();
for s in order {
let comp = find(compositions, &s.of, &s.id)?;
let (flats, locs, slots) = flatten(comp, compositions, &s.overrides, &mut Vec::new(), 0)?;
if flats.is_empty() {
return Err(format!(
"map: stamp `{}` places `{}`, which has no members. An empty composition stamps \
nothing, which looks exactly like a stamp that failed.",
s.id, comp.id
));
}
let known: BTreeSet<&str> = comp.members.iter().map(|m| m.id.as_str()).collect();
for o in &s.overrides {
if !known.contains(o.member.as_str()) {
return Err(format!(
"map: stamp `{}` overrides member `{}` of `{}`, which has no such member. It has: \
{}.",
s.id,
o.member,
comp.id,
known.iter().copied().collect::<Vec<_>>().join(", ")
));
}
if o.because.trim().is_empty() {
return Err(format!(
"map: stamp `{}` overrides member `{}` but says nothing about why. An override is \
the one place authored data re-enters a derived group; in six months only that \
sentence can say whether it still should.",
s.id, o.member
));
}
let member = comp.members.iter().find(|m| m.id == o.member);
if matches!(member.map(|m| &m.body), Some(Body::Composition { .. })) {
return Err(format!(
"map: stamp `{}` overrides member `{}` of `{}`, which is a composition. A group \
has no descriptor for a patch to decal — override one of its own members, or \
edit the group.",
s.id, o.member, comp.id
));
}
}
let y = crate::stack::resolve_y(&working, library)?;
let mut hosts: Vec<Option<String>> = Vec::with_capacity(flats.len());
for f in &flats {
match &f.on {
Some(sibling) => hosts.push(Some(stamped_id(&s.id, sibling))),
None => {
let base = library.get(&f.descriptor).ok_or_else(|| {
format!(
"map: stamp `{}` places descriptor `{}`, which the library does not \
define",
s.id, f.descriptor
)
})?;
let d = match &f.patch {
Some(p) => base.patched_with(p),
None => base.clone(),
};
let at = rotate_xz(f.at, s.yaw);
let probe = (s.at.0 + at.0, s.at.1 + at.1);
let host = crate::stack::placement_at(&working, library, &y, &d, probe)
.map_err(|e| format!("map: stamp `{}` member `{}`: {e}", s.id, f.path))?;
hosts.push(host.1.map(|p| p.id.clone()));
}
}
}
for (f, on) in flats.into_iter().zip(hosts) {
let at = rotate_xz(f.at, s.yaw);
let id = stamped_id(&s.id, &f.path);
if hand_placed.contains(id.as_str()) {
return Err(format!(
"map: stamp `{}` expands to a row named `{id}`, and the map already places one \
under that name by hand. A location's `props` could not say which it meant — \
rename the stamp or the placement.",
s.id
));
}
out.from
.insert(id.clone(), (s.id.clone(), f.path.clone()));
let placed = Placed {
id,
descriptor: f.descriptor,
at: (s.at.0 + at.0, s.at.1 + at.1),
yaw: add_yaw(f.yaw, s.yaw),
lift: f.lift,
paint: f.paint,
tip: f.tip,
on,
owned: s.owned,
owned_because: s.owned_because.clone(),
patch: f.patch,
family: Default::default(),
note: f.note,
};
working.placements.push(placed.clone());
out.placements.push(placed);
}
for l in locs {
out.locations.push(Location {
id: stamped_id(&s.id, &l.id),
props: l.props.iter().map(|p| stamped_id(&s.id, p)).collect(),
interactions: l.interactions,
note: l.note,
});
}
for sl in slots {
let at = rotate_xz(sl.at, s.yaw);
out.slots.push(OpenSlot {
id: stamped_id(&s.id, &sl.id),
accepts: sl.accepts,
at: (s.at.0 + at.0, s.at.1 + at.1),
yaw: add_yaw(sl.yaw, s.yaw),
lift: sl.lift,
});
}
}
Ok(out)
}
pub fn expand_map(
map: &Map,
compositions: &[Composition],
library: &Library,
) -> Result<Expansion, String> {
expand(map, &map.stamps, compositions, library)
}
struct Boxed {
path: String,
x: (f32, f32),
y: (f32, f32),
z: (f32, f32),
div: (u32, u32, u32),
grid: Subgrid,
}
pub fn interface(
comp: &Composition,
compositions: &[Composition],
library: &Library,
per_tile: u32,
) -> Result<Option<Interface>, String> {
let Envelope::Bounded { size } = comp.envelope else {
return Ok(None);
};
if per_tile == 0 {
return Err(format!(
"composition: `{}` cannot derive an interface — the project divides each tile 0 ways",
comp.id
));
}
let (flats, _, _) = flatten(comp, compositions, &[], &mut Vec::new(), 0)?;
let mut scratch = Map {
version: crate::map::MAP_VERSION,
name: "composition_envelope".to_owned(),
origin: (0.0, 0.0, 0.0),
bounds: size,
bash: None,
placements: Vec::new(),
stamps: Vec::new(),
runs: Vec::new(),
locations: Vec::new(),
note: None,
enclosure: crate::map::Enclosure::default(),
};
for f in &flats {
scratch.placements.push(Placed {
paint: 0,
id: f.path.clone(),
descriptor: f.descriptor.clone(),
at: f.at,
yaw: f.yaw,
lift: f.lift,
tip: f.tip,
on: f.on.clone(),
owned: false,
owned_because: None,
family: Default::default(),
patch: f.patch.clone(),
note: None,
});
}
let ys = crate::stack::resolve_y(&scratch, library).map_err(|e| {
format!(
"composition: `{}` cannot derive an interface — {e}. Every member has to have a height \
before the boundary can be read.",
comp.id
)
})?;
let mut boxes: Vec<Boxed> = Vec::new();
for (i, f) in flats.iter().enumerate() {
let base = library.get(&f.descriptor).ok_or_else(|| {
format!(
"composition: `{}` member `{}` places descriptor `{}`, which the library does not \
define",
comp.id, f.path, f.descriptor
)
})?;
let d = match &f.patch {
Some(p) => base.patched_with(p),
None => base.clone(),
};
let Some(g) = d.subgrid.clone() else { continue };
if !g.cells.iter().any(|c| c.edge.is_some()) {
continue; }
let quarter = crate::adjacency::quarter_turns(&f.path, f.yaw)?;
let div = crate::descriptor::divisions(&d, per_tile)?;
let (w, h, dep) = crate::descriptor::tipped_extents(&d, f.tip).ok_or_else(|| {
format!(
"composition: `{}` member `{}` is unmeasured, so its boundary cannot be read",
comp.id, f.path
)
})?;
let turned_div = crate::descriptor::rotate_div(div, quarter);
let grid = g.rotated(quarter, div);
let (sw, sd) = if quarter % 2 == 1 { (dep, w) } else { (w, dep) };
let y0 = ys.get(i).copied().unwrap_or(0.0);
boxes.push(Boxed {
path: f.path.clone(),
x: (f.at.0 - sw * 0.5, f.at.0 + sw * 0.5),
y: (y0, y0 + h),
z: (f.at.1 - sd * 0.5, f.at.1 + sd * 0.5),
div: turned_div,
grid,
});
}
let subunit = crate::grid::SNAP / per_tile as f32;
let half = (size.0 * 0.5, size.2 * 0.5);
let env = (
(-half.0, half.0),
(0.0, size.1),
(-half.1, half.1),
);
let mut faces: [Vec<Band>; 4] = Default::default();
let mut faults = Vec::new();
for dir in [N, E, S, W] {
let lateral_is_z = dir == E || dir == W;
let lat = if lateral_is_z { env.2 } else { env.0 };
let steps = |span: (f32, f32)| (((span.1 - span.0) / subunit).round() as u32).max(1);
let (n_lat, n_y) = (steps(lat), steps(env.1));
let mut rows: Vec<Vec<Option<String>>> = Vec::with_capacity(n_y as usize);
for iy in 0..n_y {
let wy = env.1.0 + (iy as f32 + 0.5) * (env.1.1 - env.1.0) / n_y as f32;
let mut row: Vec<Option<String>> = Vec::with_capacity(n_lat as usize);
for il in 0..n_lat {
let wl = lat.0 + (il as f32 + 0.5) * (lat.1 - lat.0) / n_lat as f32;
let mut found: Option<(&str, Option<&str>)> = None;
for b in &boxes {
let on_face = match dir {
E => (b.x.1 - env.0.1).abs() <= crate::adjacency::EDGE_EPSILON,
W => (b.x.0 - env.0.0).abs() <= crate::adjacency::EDGE_EPSILON,
N => (b.z.0 - env.2.0).abs() <= crate::adjacency::EDGE_EPSILON,
S => (b.z.1 - env.2.1).abs() <= crate::adjacency::EDGE_EPSILON,
_ => false,
};
if !on_face || wy < b.y.0 || wy > b.y.1 {
continue;
}
let (lo, hi) = if lateral_is_z { b.z } else { b.x };
if wl < lo || wl > hi {
continue;
}
let ay = crate::adjacency::index(wy, b.y, b.div.1);
let al = crate::adjacency::index(wl, (lo, hi), if lateral_is_z { b.div.2 } else { b.div.0 });
let last = |n: u32| n.saturating_sub(1);
let cell = match dir {
E => (last(b.div.0), ay, al),
W => (0, ay, al),
N => (al, ay, 0),
S => (al, ay, last(b.div.2)),
_ => continue,
};
let token = b.grid.at(cell).and_then(|c| c.edge.as_deref());
match found {
None => found = Some((b.path.as_str(), token)),
Some((other, other_token)) if other_token != token => {
faults.push(InterfaceFault {
dir,
a: other.to_owned(),
b: b.path.clone(),
a_token: other_token.map(str::to_owned),
b_token: token.map(str::to_owned),
message: format!(
"`{}`: `{other}` presents {} on its {} face where `{}` presents \
{} — the group has no single answer for what abuts it there",
comp.id,
other_token.unwrap_or("nothing"),
dir_name(dir),
b.path,
token.unwrap_or("nothing"),
),
});
}
Some(_) => {}
}
}
row.push(found.and_then(|(_, t)| t.map(str::to_owned)));
}
rows.push(row);
}
faces[dir] = into_bands(&rows, lat, env.1);
}
faults.sort_by(|p, q| (p.dir, &p.a, &p.b).cmp(&(q.dir, &q.a, &q.b)));
faults.dedup_by(|p, q| p.dir == q.dir && p.a == q.a && p.b == q.b);
Ok(Some(Interface { faces, faults }))
}
fn into_bands(rows: &[Vec<Option<String>>], lat: (f32, f32), yspan: (f32, f32)) -> Vec<Band> {
let n_y = rows.len();
let n_lat = rows.first().map_or(0, Vec::len);
if n_y == 0 || n_lat == 0 {
return Vec::new();
}
let y_at = |i: usize| yspan.0 + (yspan.1 - yspan.0) * i as f32 / n_y as f32;
let l_at = |i: usize| lat.0 + (lat.1 - lat.0) * i as f32 / n_lat as f32;
let mut out = Vec::new();
let mut iy = 0;
while iy < n_y {
let Some(row) = rows.get(iy) else { break };
let mut ny = 1;
while rows.get(iy + ny).is_some_and(|r| r == row) {
ny += 1;
}
let y = (y_at(iy), y_at(iy + ny));
let mut il = 0;
while il < n_lat {
let token = row.get(il).cloned().flatten();
let mut nl = 1;
while il + nl < n_lat && row.get(il + nl).cloned().flatten() == token {
nl += 1;
}
out.push(Band { y, lat: (l_at(il), l_at(il + nl)), token });
il += nl;
}
iy += ny;
}
out
}
fn dir_name(dir: Dir) -> &'static str {
match dir {
N => "north",
E => "east",
S => "south",
W => "west",
_ => "unknown",
}
}
pub const COMPOSITIONS_VERSION: u32 = 1;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Compositions {
pub version: u32,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub compositions: Vec<Composition>,
}
impl Compositions {
pub fn parse(text: &str) -> Result<Compositions, String> {
let set: Compositions = ron::from_str(text).map_err(|e| format!("compositions: {e}"))?;
if set.version > COMPOSITIONS_VERSION {
return Err(format!(
"compositions: version {} but this build reads {COMPOSITIONS_VERSION} — refusing a \
file written by a newer tool rather than dropping what it says",
set.version
));
}
Ok(set)
}
pub fn to_ron(&self) -> Result<String, String> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.map_err(|e| format!("compositions: serialize: {e}"))
}
pub const FILE: &'static str = "compositions.ron";
}
pub fn record_fingerprints(
comp: &mut Composition,
compositions: &[Composition],
library: &Library,
) -> Result<usize, String> {
let mut changed = 0;
for m in &mut comp.members {
let measured = body_fingerprint_of(&m.body, compositions, library)?;
if m.of_fingerprint != Some(measured) {
m.of_fingerprint = Some(measured);
changed += 1;
}
}
Ok(changed)
}
pub fn rename_composition(
compositions_text: &str,
map_texts: &[(String, String)],
from: &str,
to: &str,
) -> Result<RenamedComposition, String> {
if !crate::naming::is_id(to) {
return Err(format!(
"`{to}` is not a usable composition id. Ids are lowercase, separated by `/`, and each \
part starts with a letter, has no `__`, and does not end in `_`."
));
}
if from == to {
return Err(format!("`{from}` is already its own name."));
}
let set = Compositions::parse(compositions_text)?;
if !set.compositions.iter().any(|c| c.id == from) {
return Err(format!(
"there is no composition called `{from}` to rename."
));
}
if set.compositions.iter().any(|c| c.id == to) {
return Err(format!(
"`{to}` is already a composition, so renaming `{from}` to it would give two \
compositions the same name."
));
}
let index = set
.compositions
.iter()
.position(|c| c.id == from)
.expect("the composition was found above");
let new_compositions = replace_leaf(
compositions_text,
&format!("compositions[{index}].id"),
from,
to,
"the composition's id",
)?;
let mut maps = Vec::with_capacity(map_texts.len());
for (name, text) in map_texts {
if !text.contains(from) {
maps.push((name.clone(), text.clone()));
continue;
}
let mut edited = text.clone();
let mut guard = 0usize;
loop {
guard += 1;
if guard > 4096 {
return Err(format!(
"map `{name}` names `{from}` more times than a map can hold; refusing rather \
than editing forever."
));
}
let leaves = crate::ron_surgery::scan_ron_leaves(&edited)?;
let wanted = ron_string(from);
let Some(leaf) = leaves.iter().find(|l| {
l.path.starts_with("stamps[") && l.path.ends_with(".of") && l.text == wanted
}) else {
break;
};
let start = leaf.span.start;
let end = leaf.span.end;
edited = format!("{}{}{}", &edited[..start], ron_string(to), &edited[end..]);
}
maps.push((name.clone(), edited));
}
Ok(RenamedComposition {
compositions: new_compositions,
maps,
from: from.to_owned(),
to: to.to_owned(),
})
}
#[derive(Clone, Debug, PartialEq)]
pub struct RenamedComposition {
pub compositions: String,
pub maps: Vec<(String, String)>,
pub from: String,
pub to: String,
}
fn replace_leaf(
text: &str,
path: &str,
expect: &str,
value: &str,
what: &str,
) -> Result<String, String> {
let leaves = crate::ron_surgery::scan_ron_leaves(text)?;
let leaf = leaves
.iter()
.find(|l| l.path == path)
.ok_or_else(|| format!("the file has no `{path}` — so {what} is not there to rename."))?;
let expected = ron_string(expect);
if leaf.text != expected {
return Err(format!(
"the file's `{path}` says `{}` and not `{expect}` — so it is not the thing being \
renamed. Nothing was changed.",
leaf.text
));
}
let start = leaf.span.start;
let end = leaf.span.end;
Ok(format!(
"{}{}{}",
&text[..start],
ron_string(value),
&text[end..]
))
}
fn ron_string(value: &str) -> String {
ron::ser::to_string(value).unwrap_or_else(|_| format!("\"{value}\""))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{Extent, Mount, Offers, SubCell};
use crate::library::LIBRARY_VERSION;
fn piece(id: &str, w: f32, d: f32, h: f32) -> Descriptor {
Descriptor {
id: id.to_owned(),
extent: Extent { footprint: Some((w, d)), height: Some(h) },
..Default::default()
}
}
fn tiled(id: &str, w: f32, d: f32, h: f32, token: &str) -> Descriptor {
tiled_divided(id, w, d, h, token, 1)
}
fn tiled_divided(id: &str, w: f32, d: f32, h: f32, token: &str, per_tile: u32) -> Descriptor {
let mut p = piece(id, w, d, h);
let div = crate::descriptor::divisions(&p, per_tile).expect("measured");
let mut cells = Vec::new();
for x in 0..div.0 {
for y in 0..div.1 {
for z in 0..div.2 {
cells.push(SubCell {
at: (x, y, z),
solid: true,
edge: Some(token.to_owned()),
});
}
}
}
p.subgrid = Some(Subgrid { cells });
p
}
#[test]
fn an_empty_composition_is_refused_at_the_door_and_at_the_stamp() {
let comp = Composition {
id: "half_built".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 2.4, 1.0) },
members: Vec::new(),
locations: Vec::new(),
note: None,
};
let lib = library(vec![piece("floor", 1.0, 1.0, 0.1)]);
let err = comp.validate_shape().expect_err("an empty composition must not load");
assert!(err.contains("half_built"), "{err}");
assert!(err.contains("no members"), "{err}");
let err = validate(std::slice::from_ref(&comp), &lib)
.expect_err("nor pass the whole-set validation the loader runs");
assert!(err.contains("half_built"), "{err}");
let stamps = vec![Stamped {
id: "s1".to_owned(),
of: "half_built".to_owned(),
..Default::default()
}];
let err = expand(&empty_map(), &stamps, std::slice::from_ref(&comp), &lib)
.expect_err("stamping an empty composition has to refuse");
assert!(err.contains("s1"), "the stamp is not named: {err}");
assert!(err.contains("half_built"), "the composition is not named: {err}");
}
#[test]
fn a_nested_empty_composition_is_named_rather_than_its_parent() {
let alcove = Composition {
id: "alcove".to_owned(),
envelope: Envelope::Anchored,
members: Vec::new(),
locations: Vec::new(),
note: None,
};
let room = Composition {
id: "room".to_owned(),
envelope: Envelope::Anchored,
members: vec![Member {
id: "nook".to_owned(),
body: Body::Composition { id: "alcove".to_owned() },
at: (0.0, 0.0),
yaw: 0.0,
lift: 0.0,
paint: 0,
of_fingerprint: None,
note: None,
}],
locations: Vec::new(),
note: None,
};
let lib = library(vec![piece("floor", 1.0, 1.0, 0.1)]);
let err = validate(&[alcove, room], &lib).expect_err("the empty inner one must refuse");
assert!(
err.contains("alcove"),
"the refusal has to name the composition to go and edit, not its parent: {err}"
);
}
fn library(descriptors: Vec<Descriptor>) -> Library {
Library { version: LIBRARY_VERSION, note: None, descriptors }
}
fn empty_map() -> Map {
Map {
version: crate::map::MAP_VERSION,
name: "scratch".to_owned(),
origin: (0.0, 0.0, 0.0),
bounds: (32.0, 4.0, 32.0),
bash: None,
placements: Vec::new(),
stamps: Vec::new(),
locations: Vec::new(),
note: None,
..Default::default()
}
}
fn member(id: &str, descriptor: &str, at: (f32, f32)) -> Member {
Member {
id: id.to_owned(),
paint: 0,
body: Body::Descriptor {
id: descriptor.to_owned(),
tip: (0, 0),
on: None,
patch: None,
},
at,
yaw: 0.0,
lift: 0.0,
of_fingerprint: None,
note: None,
}
}
fn stamp(id: &str, of: &str, at: (f32, f32), yaw: f32) -> Stamped {
Stamped {
id: id.to_owned(),
of: of.to_owned(),
at,
yaw,
..Default::default()
}
}
fn record_all(comps: &mut [Composition], lib: &Library) {
let snapshot = comps.to_vec();
for c in comps.iter_mut() {
super::record_fingerprints(c, &snapshot, lib).expect("records");
}
}
fn simple() -> (Library, Vec<Composition>) {
let lib = library(vec![piece("desk", 1.0, 1.0, 0.8), piece("chair", 0.5, 0.5, 1.0)]);
let comp = Composition {
id: "workstation".to_owned(),
envelope: Envelope::Anchored,
members: vec![member("chair", "chair", (0.0, 1.0)), member("desk", "desk", (0.0, 0.0))],
locations: Vec::new(),
note: None,
};
(lib, vec![comp])
}
#[test]
fn expansion_does_not_depend_on_stamp_order() {
let (lib, comps) = simple();
let map = empty_map();
let a = stamp("alpha", "workstation", (2.0, 0.0), 0.0);
let b = stamp("beta", "workstation", (-2.0, 0.0), 90.0);
let one = expand(&map, &[a.clone(), b.clone()], &comps, &lib).expect("expands");
let two = expand(&map, &[b, a], &comps, &lib).expect("expands");
assert_eq!(one.placements, two.placements);
assert_eq!(one.from, two.from);
assert_eq!(one.placements.len(), 4);
}
#[test]
fn every_row_says_which_stamp_and_member_it_came_from() {
let (lib, comps) = simple();
let out = expand(&empty_map(), &[stamp("a1", "workstation", (0.0, 0.0), 0.0)], &comps, &lib)
.expect("expands");
let ids: Vec<&str> = out.placements.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, ["a1/chair", "a1/desk"]);
assert_eq!(
out.from.get("a1/desk"),
Some(&("a1".to_owned(), "desk".to_owned()))
);
}
#[test]
fn a_stamps_yaw_turns_the_group_about_its_anchor() {
let (lib, comps) = simple();
let out = expand(&empty_map(), &[stamp("a1", "workstation", (0.0, 0.0), 90.0)], &comps, &lib)
.expect("expands");
let chair = out.placements.iter().find(|p| p.id == "a1/chair").expect("chair");
assert!((chair.at.0 - 1.0).abs() < 1e-5, "chair x was {}", chair.at.0);
assert!(chair.at.1.abs() < 1e-5, "chair z was {}", chair.at.1);
assert!((chair.yaw - 90.0).abs() < 1e-5);
}
#[test]
fn a_sibling_host_is_repointed_to_the_row_it_became() {
let mut desk = piece("desk", 1.0, 1.0, 0.8);
desk.offers = Offers {
surfaces: vec!["desk".to_owned()],
faces: Vec::new(),
sockets: Vec::new(),
};
let mut lamp = piece("lamp", 0.2, 0.2, 0.4);
lamp.mount = Some(Mount::OnSurface { class: "desk".to_owned() });
let lib = library(vec![desk, lamp]);
let comp = Composition {
id: "lit_desk".to_owned(),
envelope: Envelope::Anchored,
members: vec![
member("desk", "desk", (0.0, 0.0)),
Member {
body: Body::Descriptor {
id: "lamp".to_owned(),
tip: (0, 0),
on: Some("desk".to_owned()),
patch: None,
},
..member("lamp", "lamp", (0.0, 0.0))
},
],
locations: Vec::new(),
note: None,
};
let out = expand(&empty_map(), &[stamp("a1", "lit_desk", (0.0, 0.0), 0.0)], &[comp], &lib)
.expect("expands");
let lamp = out.placements.iter().find(|p| p.id == "a1/lamp").expect("lamp");
assert_eq!(lamp.on.as_deref(), Some("a1/desk"));
}
#[test]
fn a_member_with_nowhere_to_rest_refuses_the_whole_stamp() {
let mut lamp = piece("lamp", 0.2, 0.2, 0.4);
lamp.mount = Some(Mount::OnSurface { class: "desk".to_owned() });
let lib = library(vec![lamp]);
let comp = Composition {
id: "floating".to_owned(),
envelope: Envelope::Anchored,
members: vec![member("lamp", "lamp", (0.0, 0.0))],
locations: Vec::new(),
note: None,
};
let err = expand(&empty_map(), &[stamp("a1", "floating", (0.0, 0.0), 0.0)], &[comp], &lib)
.expect_err("refuses");
assert!(err.contains("a1"), "{err}");
assert!(err.contains("lamp"), "{err}");
}
#[test]
fn an_expanded_id_colliding_with_a_hand_placed_row_refuses() {
let (lib, comps) = simple();
let mut map = empty_map();
map.placements.push(Placed {
id: "a1/desk".to_owned(),
descriptor: "desk".to_owned(),
..Default::default()
});
let err = expand(&map, &[stamp("a1", "workstation", (0.0, 0.0), 0.0)], &comps, &lib)
.expect_err("refuses");
assert!(err.contains("a1/desk"), "{err}");
assert!(err.contains("already places one"), "{err}");
}
#[test]
fn a_locations_props_follow_the_rows_the_stamp_made() {
let (lib, mut comps) = simple();
comps[0].locations = vec![Location {
id: "seat".to_owned(),
props: vec!["chair".to_owned(), "desk".to_owned()],
interactions: Vec::new(),
note: None,
}];
let out = expand(&empty_map(), &[stamp("a1", "workstation", (0.0, 0.0), 0.0)], &comps, &lib)
.expect("expands");
assert_eq!(out.locations.len(), 1);
assert_eq!(out.locations[0].id, "a1/seat");
assert_eq!(out.locations[0].props, ["a1/chair", "a1/desk"]);
}
#[test]
fn an_override_wins_over_a_member_patch_and_both_over_the_library() {
let lib = library(vec![piece("crate", 1.0, 1.0, 1.0)]);
let comp = Composition {
id: "stack".to_owned(),
envelope: Envelope::Anchored,
members: vec![Member {
body: Body::Descriptor {
id: "crate".to_owned(),
tip: (0, 0),
on: None,
patch: Some(Descriptor {
look: vec!["rusted".to_owned()],
note: Some("the group's own opinion".to_owned()),
..Default::default()
}),
},
..member("box", "crate", (0.0, 0.0))
}],
locations: Vec::new(),
note: None,
};
let s = Stamped {
overrides: vec![Override {
member: "box".to_owned(),
patch: Descriptor { look: vec!["scorched".to_owned()], ..Default::default() },
because: "this one is the fire-damaged corner".to_owned(),
}],
..stamp("a1", "stack", (0.0, 0.0), 0.0)
};
let out = expand(&empty_map(), &[s], &[comp], &lib).expect("expands");
let patch = out.placements[0].patch.as_ref().expect("carries a patch");
assert_eq!(patch.look, ["scorched"]);
assert_eq!(patch.note.as_deref(), Some("the group's own opinion"));
assert!(lib.get("crate").expect("in library").look.is_empty());
}
#[test]
fn an_override_on_a_missing_member_refuses_and_names_what_there_is() {
let (lib, comps) = simple();
let s = Stamped {
overrides: vec![Override {
member: "stool".to_owned(),
patch: Descriptor::default(),
because: "wrong name".to_owned(),
}],
..stamp("a1", "workstation", (0.0, 0.0), 0.0)
};
let err = expand(&empty_map(), &[s], &comps, &lib).expect_err("refuses");
assert!(err.contains("stool"), "{err}");
assert!(err.contains("chair") && err.contains("desk"), "{err}");
}
#[test]
fn an_override_without_a_reason_is_refused() {
let (lib, comps) = simple();
let s = Stamped {
overrides: vec![Override {
member: "desk".to_owned(),
patch: Descriptor::default(),
because: " ".to_owned(),
}],
..stamp("a1", "workstation", (0.0, 0.0), 0.0)
};
let err = expand(&empty_map(), &[s], &comps, &lib).expect_err("refuses");
assert!(err.contains("says nothing about why"), "{err}");
}
#[test]
fn a_stamp_cannot_override_a_nested_composition() {
let (lib, mut comps) = simple();
comps.push(Composition {
id: "office".to_owned(),
envelope: Envelope::Anchored,
members: vec![Member {
body: Body::Composition { id: "workstation".to_owned() },
..member("station", "unused", (0.0, 0.0))
}],
locations: Vec::new(),
note: None,
});
let s = Stamped {
overrides: vec![Override {
member: "station".to_owned(),
patch: Descriptor::default(),
because: "reaching in".to_owned(),
}],
..stamp("a1", "office", (0.0, 0.0), 0.0)
};
let err = expand(&empty_map(), &[s], &comps, &lib).expect_err("refuses");
assert!(err.contains("is a composition"), "{err}");
}
#[test]
fn a_nested_groups_transform_folds_into_its_members() {
let (lib, mut comps) = simple();
comps.push(Composition {
id: "office".to_owned(),
envelope: Envelope::Anchored,
members: vec![Member {
body: Body::Composition { id: "workstation".to_owned() },
yaw: 90.0,
..member("station", "unused", (5.0, 0.0))
}],
locations: Vec::new(),
note: None,
});
let out = expand(&empty_map(), &[stamp("a1", "office", (0.0, 0.0), 0.0)], &comps, &lib)
.expect("expands");
let chair = out
.placements
.iter()
.find(|p| p.id == "a1/station/chair")
.expect("the nested chair");
assert!((chair.at.0 - 6.0).abs() < 1e-5, "x was {}", chair.at.0);
assert!(chair.at.1.abs() < 1e-5, "z was {}", chair.at.1);
assert!((chair.yaw - 90.0).abs() < 1e-5);
}
#[test]
fn a_group_containing_itself_refuses_and_names_the_loop() {
let lib = library(vec![piece("desk", 1.0, 1.0, 0.8)]);
let a = Composition {
id: "a".to_owned(),
envelope: Envelope::Anchored,
members: vec![Member {
body: Body::Composition { id: "b".to_owned() },
..member("inner", "unused", (0.0, 0.0))
}],
locations: Vec::new(),
note: None,
};
let b = Composition {
id: "b".to_owned(),
members: vec![Member {
body: Body::Composition { id: "a".to_owned() },
..member("inner", "unused", (0.0, 0.0))
}],
..a.clone()
};
let err = validate(&[a, b], &lib).expect_err("refuses");
assert!(err.contains("contains itself"), "{err}");
assert!(err.contains("a → b → a"), "{err}");
}
#[test]
fn nesting_past_the_depth_cap_refuses_and_names_the_chain() {
let lib = library(vec![piece("desk", 1.0, 1.0, 0.8)]);
let deepest = MAX_COMPOSITION_DEPTH as usize + 3;
let mut comps: Vec<Composition> = Vec::new();
for i in 0..deepest {
let body = if i + 1 == deepest {
Body::Descriptor { id: "desk".to_owned(), tip: (0, 0), on: None, patch: None }
} else {
Body::Composition { id: format!("c{}", i + 1) }
};
comps.push(Composition {
id: format!("c{i}"),
envelope: Envelope::Anchored,
members: vec![Member { body, ..member("inner", "unused", (0.0, 0.0)) }],
locations: Vec::new(),
note: None,
});
}
let err = validate(&comps, &lib).expect_err("refuses");
assert!(err.contains("nests deeper"), "{err}");
assert!(err.contains(&format!("{MAX_COMPOSITION_DEPTH}")), "{err}");
assert!(err.contains("c0 → c1"), "{err}");
}
#[test]
fn a_group_over_the_row_cap_refuses_with_the_count() {
let lib = library(vec![piece("desk", 1.0, 1.0, 0.8)]);
let over = MAX_RESOLVED_MEMBERS + 1;
let members: Vec<Member> = (0..over)
.map(|i| member(&format!("m{i:04}"), "desk", (i as f32, 0.0)))
.collect();
let comp = Composition {
id: "too_much".to_owned(),
envelope: Envelope::Anchored,
members,
locations: Vec::new(),
note: None,
};
let err = validate(&[comp], &lib).expect_err("refuses");
assert!(err.contains(&over.to_string()), "{err}");
assert!(err.contains(&MAX_RESOLVED_MEMBERS.to_string()), "{err}");
}
#[test]
fn members_out_of_canonical_order_refuse_and_say_the_order() {
let (lib, mut comps) = simple();
comps[0].members.reverse();
let err = validate(&comps, &lib).expect_err("refuses");
assert!(err.contains("out of order"), "{err}");
assert!(err.contains("chair, desk"), "{err}");
}
#[test]
fn a_host_that_is_not_a_member_refuses() {
let (lib, mut comps) = simple();
comps[0].members[0].body = Body::Descriptor {
id: "chair".to_owned(),
tip: (0, 0),
on: Some("bench".to_owned()),
patch: None,
};
let err = validate(&comps, &lib).expect_err("refuses");
assert!(err.contains("bench"), "{err}");
assert!(err.contains("no host at all"), "{err}");
}
#[test]
fn a_member_naming_an_unknown_descriptor_refuses() {
let (lib, mut comps) = simple();
comps[0].members.push(member("zzz", "hovercraft", (0.0, 0.0)));
let err = validate(&comps, &lib).expect_err("refuses");
assert!(err.contains("hovercraft"), "{err}");
}
#[test]
fn two_stamps_sharing_an_id_refuse() {
let (lib, comps) = simple();
let a = stamp("a1", "workstation", (0.0, 0.0), 0.0);
let err = expand(&empty_map(), &[a.clone(), a], &comps, &lib).expect_err("refuses");
assert!(err.contains("used twice"), "{err}");
}
#[test]
fn the_fingerprint_encoding_is_pinned() {
let d = piece("desk", 1.5, 0.75, 0.8);
assert_eq!(descriptor_fingerprint(&d), 0x3a75_2a36_e217_24bf);
}
#[test]
fn the_fingerprint_encoding_is_pinned_for_a_mounted_piece() {
let mut d = piece("shelf_lamp", 0.25, 0.25, 0.4);
d.mount = Some(Mount::OnSurface { class: "shelf".to_owned() });
d.align.front = Some(crate::descriptor::Face::East);
d.clearance = vec![crate::descriptor::Clearance { dir: ClearDir::Front, dist: 0.6 }];
assert_eq!(descriptor_fingerprint(&d), 0x6f64_9e68_f29d_36f0);
}
#[test]
fn different_mounts_fingerprint_differently() {
let mut a = piece("thing", 0.5, 0.5, 0.5);
a.mount = Some(Mount::OnWall { height: 1.2 });
let mut b = a.clone();
b.mount = Some(Mount::OnWall { height: 1.6 });
let mut c = a.clone();
c.mount = Some(Mount::OnCeiling);
let fa = descriptor_fingerprint(&a);
assert_ne!(fa, descriptor_fingerprint(&b), "height must matter");
assert_ne!(fa, descriptor_fingerprint(&c), "the variant must matter");
}
#[test]
fn a_reworded_note_does_not_move_the_fingerprint() {
let mut a = piece("desk", 1.0, 1.0, 0.8);
let before = descriptor_fingerprint(&a);
a.note = Some("actually the good desk".to_owned());
a.look = vec!["walnut".to_owned()];
assert_eq!(descriptor_fingerprint(&a), before);
}
#[test]
fn resizing_a_piece_moves_the_fingerprint() {
let a = piece("desk", 1.0, 1.0, 0.8);
let b = piece("desk", 1.2, 1.0, 0.8);
assert_ne!(descriptor_fingerprint(&a), descriptor_fingerprint(&b));
}
#[test]
fn a_hand_written_group_reads_unrecorded_rather_than_stale() {
let (lib, comps) = simple();
let report = stale_members(&comps[0], &comps, &lib).expect("checks");
assert_eq!(report.len(), 2);
assert!(report.iter().all(|s| s.freshness == Freshness::Unrecorded));
assert!(report.iter().all(|s| s.recorded.is_none()));
}
#[test]
fn recording_is_idempotent_and_says_what_it_changed() {
let (lib, mut comps) = simple();
let snapshot = comps.clone();
let first = record_fingerprints(&mut comps[0], &snapshot, &lib).expect("records");
assert_eq!(first, 2);
let again = record_fingerprints(&mut comps[0], &snapshot, &lib).expect("records");
assert_eq!(again, 0, "nothing changed, so nothing to record");
assert!(stale_members(&comps[0], &comps, &lib).expect("checks").is_empty());
}
#[test]
fn a_member_whose_body_changed_reads_stale() {
let (lib, mut comps) = simple();
record_all(&mut comps, &lib);
assert!(stale_members(&comps[0], &comps, &lib).expect("checks").is_empty());
let grown = library(vec![piece("desk", 2.0, 1.0, 0.8), piece("chair", 0.5, 0.5, 1.0)]);
let stale = stale_members(&comps[0], &comps, &grown).expect("checks");
assert_eq!(stale.len(), 1);
assert_eq!(stale[0].member, "desk");
assert_eq!(stale[0].freshness, Freshness::Stale);
assert_ne!(stale[0].recorded, Some(stale[0].measured));
}
#[test]
fn an_edit_that_does_not_change_the_interface_marks_nothing_stale() {
let (lib, mut comps) = simple();
record_all(&mut comps, &lib);
let mut reworded = lib.descriptors.clone();
reworded[0].note = Some("the one by the window".to_owned());
reworded[0].look = vec!["oak".to_owned()];
let after = library(reworded);
assert!(
stale_members(&comps[0], &comps, &after).expect("checks").is_empty(),
"a note and a look are not something composition depends on"
);
}
#[test]
fn an_anchored_group_has_no_interface() {
let (lib, comps) = simple();
assert!(interface(&comps[0], &comps, &lib, 1).expect("derives").is_none());
}
#[test]
fn a_bounded_groups_face_is_read_off_its_members() {
let lib = library(vec![tiled("wall", 1.0, 1.0, 1.0, "wall")]);
let comp = Composition {
id: "bay".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 1.0, 1.0) },
members: vec![member("wall", "wall", (0.0, 0.0))],
locations: Vec::new(),
note: None,
};
let iface = interface(&comp, std::slice::from_ref(&comp), &lib, 1)
.expect("derives")
.expect("bounded");
assert!(iface.is_clean(), "{:?}", iface.faults);
assert_eq!(iface.faces[E].len(), 1, "east read {:?}", iface.faces[E]);
assert_eq!(iface.faces[E][0].token.as_deref(), Some("wall"));
assert_eq!(iface.faces[E][0].y, (0.0, 1.0));
assert_eq!(iface.faces[E][0].lat, (-0.5, 0.5));
}
#[test]
fn dividing_a_tile_more_finely_does_not_change_what_a_face_presents() {
let comp = Composition {
id: "bay".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 1.0, 1.0) },
members: vec![member("wall", "wall", (0.0, 0.0))],
locations: Vec::new(),
note: None,
};
let comps = vec![comp.clone()];
let read = |per_tile: u32| {
let lib = library(vec![tiled_divided("wall", 1.0, 1.0, 1.0, "wall", per_tile)]);
interface(&comp, &comps, &lib, per_tile).expect("derives").expect("bounded").faces
};
assert_eq!(read(1), read(8), "1 division read differently from 8");
}
#[test]
fn a_gap_between_two_members_bands_the_face_rather_than_faulting_it() {
let lib = library(vec![tiled("jamb", 1.0, 1.0, 2.0, "wall")]);
let comp = Composition {
id: "doorway".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 2.0, 3.0) },
members: vec![
member("jamb_north", "jamb", (0.0, -1.0)),
member("jamb_south", "jamb", (0.0, 1.0)),
],
locations: Vec::new(),
note: None,
};
let iface = interface(&comp, std::slice::from_ref(&comp), &lib, 1)
.expect("derives")
.expect("bounded");
assert!(iface.is_clean(), "a gap is not a disagreement: {:?}", iface.faults);
let east: Vec<_> = iface.faces[E]
.iter()
.map(|b| (b.lat, b.token.as_deref()))
.collect();
assert_eq!(
east,
vec![
((-1.5, -0.5), Some("wall")),
((-0.5, 0.5), None),
((0.5, 1.5), Some("wall")),
],
"east read {:?}",
iface.faces[E]
);
}
#[test]
fn a_member_shorter_than_the_envelope_leaves_the_height_above_it_unlabelled() {
let lib = library(vec![tiled("low", 1.0, 1.0, 1.0, "wall")]);
let comp = Composition {
id: "parapet".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 2.0, 1.0) },
members: vec![member("low", "low", (0.0, 0.0))],
locations: Vec::new(),
note: None,
};
let iface = interface(&comp, std::slice::from_ref(&comp), &lib, 1)
.expect("derives")
.expect("bounded");
let east: Vec<_> = iface.faces[E].iter().map(|b| (b.y, b.token.as_deref())).collect();
assert_eq!(
east,
vec![((0.0, 1.0), Some("wall")), ((1.0, 2.0), None)],
"east read {:?}",
iface.faces[E]
);
}
#[test]
fn a_face_no_member_reaches_reads_nothing() {
let lib = library(vec![tiled("wall", 1.0, 1.0, 1.0, "wall")]);
let comp = Composition {
id: "half_bay".to_owned(),
envelope: Envelope::Bounded { size: (2.0, 1.0, 1.0) },
members: vec![member("wall", "wall", (-0.5, 0.0))],
locations: Vec::new(),
note: None,
};
let iface = interface(&comp, std::slice::from_ref(&comp), &lib, 1)
.expect("derives")
.expect("bounded");
assert_eq!(iface.faces[W].len(), 1, "west read {:?}", iface.faces[W]);
assert_eq!(iface.faces[W][0].token.as_deref(), Some("wall"));
assert_eq!(iface.faces[E].len(), 1, "east read {:?}", iface.faces[E]);
assert_eq!(iface.faces[E][0].token, None, "east read {:?}", iface.faces[E]);
}
#[test]
fn members_disagreeing_about_a_face_produce_a_fault() {
let lib = library(vec![
tiled("wall", 1.0, 1.0, 1.0, "wall"),
tiled("panel", 1.0, 1.0, 1.0, "glass"),
]);
let comp = Composition {
id: "clash".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 1.0, 1.0) },
members: vec![
member("panel", "panel", (0.0, 0.0)),
member("wall", "wall", (0.0, 0.0)),
],
locations: Vec::new(),
note: None,
};
let iface = interface(&comp, std::slice::from_ref(&comp), &lib, 1)
.expect("derives")
.expect("bounded");
assert!(!iface.is_clean());
let f = &iface.faults[0];
assert!(f.message.contains("glass"), "{}", f.message);
assert!(f.message.contains("wall"), "{}", f.message);
assert!(f.message.contains("no single answer"), "{}", f.message);
}
#[test]
fn a_nested_group_contributes_to_the_parents_face() {
let lib = library(vec![tiled("wall", 1.0, 1.0, 1.0, "wall")]);
let inner = Composition {
id: "inner".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 1.0, 1.0) },
members: vec![member("wall", "wall", (0.0, 0.0))],
locations: Vec::new(),
note: None,
};
let outer = Composition {
id: "outer".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 1.0, 1.0) },
members: vec![Member {
body: Body::Composition { id: "inner".to_owned() },
..member("part", "unused", (0.0, 0.0))
}],
locations: Vec::new(),
note: None,
};
let comps = vec![inner, outer.clone()];
let iface = interface(&outer, &comps, &lib, 1).expect("derives").expect("bounded");
assert!(iface.is_clean(), "{:?}", iface.faults);
assert_eq!(iface.faces[N].len(), 1, "north read {:?}", iface.faces[N]);
assert_eq!(iface.faces[N][0].token.as_deref(), Some("wall"));
}
fn slot(id: &str, accepts: &str, at: (f32, f32), lift: f32) -> Member {
Member {
body: Body::Slot { accepts: accepts.to_owned() },
lift,
..member(id, "unused", at)
}
}
fn tile_with_slot() -> (Library, Composition) {
let lib = library(vec![tiled("wall", 1.0, 1.0, 1.0, "wall")]);
let comp = Composition {
id: "site/tile_wall_n".to_owned(),
envelope: Envelope::Bounded { size: (1.0, 1.0, 1.0) },
members: vec![
slot("fixture", "wall-fixture", (0.0, -0.2), 0.5),
member("wall", "wall", (0.0, 0.0)),
],
locations: Vec::new(),
note: None,
};
(lib, comp)
}
#[test]
fn a_slot_contributes_nothing_to_the_interface() {
let (lib, with) = tile_with_slot();
let mut without = with.clone();
without.members.retain(|m| !matches!(m.body, Body::Slot { .. }));
let read = |c: &Composition| {
interface(c, std::slice::from_ref(c), &lib, 1).expect("derives").expect("bounded")
};
let (a, b) = (read(&with), read(&without));
assert!(a.is_clean(), "{:?}", a.faults);
assert_eq!(a.faces, b.faces, "the hole moved a band");
}
#[test]
fn a_slot_on_or_outside_the_envelope_is_refused_by_name() {
let (lib, base) = tile_with_slot();
for (at, lift, why) in [
((0.5, 0.0), 0.5, "on the east seam"),
((0.0, -0.5), 0.5, "on the north seam"),
((0.9, 0.0), 0.5, "outside"),
((0.0, 0.0), 1.0, "at the ceiling"),
((0.0, 0.0), -0.1, "below the floor"),
] {
let mut c = base.clone();
c.members[0] = slot("fixture", "wall-fixture", at, lift);
let err = validate(&[c], &lib).expect_err(&format!("{why} must be refused"));
assert!(err.contains("slot `fixture`"), "{why}: {err}");
assert!(err.contains("envelope"), "{why}: {err}");
}
validate(&[base], &lib).expect("an interior slot is fine");
}
#[test]
fn a_stamped_slot_lands_where_the_stamp_puts_it() {
let (lib, comp) = tile_with_slot();
let comps = vec![comp];
let mut map = Map { version: crate::map::MAP_VERSION, ..Default::default() };
map.stamps = vec![stamp("s1", "site/tile_wall_n", (4.0, 2.0), 90.0)];
let out = expand(&map, &map.stamps, &comps, &lib).expect("expands");
assert_eq!(out.slots.len(), 1, "{:?}", out.slots);
let s = &out.slots[0];
assert_eq!(s.id, "s1/fixture");
assert_eq!(s.accepts, "wall-fixture");
assert_eq!(s.lift, 0.5);
let turned = rotate_xz((0.0, -0.2), 90.0);
assert!(
(s.at.0 - (4.0 + turned.0)).abs() < 1e-5 && (s.at.1 - (2.0 + turned.1)).abs() < 1e-5,
"{:?} is not the stamp's transform of the member",
s.at
);
assert!(out.placements.iter().all(|p| p.id != "s1/fixture"));
}
#[test]
fn changing_what_a_slot_accepts_makes_it_stale() {
let (lib, comp) = tile_with_slot();
let mut comps = vec![comp];
record_all(&mut comps, &lib);
assert!(stale_members(&comps[0], &comps, &lib).expect("reads").is_empty());
comps[0].members[0].body = Body::Slot { accepts: "floor-decal".to_owned() };
let stale = stale_members(&comps[0], &comps, &lib).expect("reads");
assert_eq!(stale.len(), 1, "{stale:?}");
assert_eq!(stale[0].member, "fixture");
assert_eq!(stale[0].freshness, Freshness::Stale);
}
#[test]
fn a_nested_slot_folds_through_like_a_row() {
let (lib, inner) = tile_with_slot();
let outer = Composition {
id: "outer".to_owned(),
envelope: Envelope::Bounded { size: (2.0, 1.0, 2.0) },
members: vec![Member {
body: Body::Composition { id: "site/tile_wall_n".to_owned() },
yaw: 180.0,
..member("half", "unused", (0.5, 0.0))
}],
locations: Vec::new(),
note: None,
};
let comps = vec![inner, outer];
let mut map = Map { version: crate::map::MAP_VERSION, ..Default::default() };
map.stamps = vec![stamp("s1", "outer", (0.0, 0.0), 0.0)];
let out = expand(&map, &map.stamps, &comps, &lib).expect("expands");
assert_eq!(out.slots.len(), 1, "{:?}", out.slots);
assert_eq!(out.slots[0].id, "s1/half/fixture", "the path must record every level");
let turned = rotate_xz((0.0, -0.2), 180.0);
assert!(
(out.slots[0].at.0 - (0.5 + turned.0)).abs() < 1e-5
&& (out.slots[0].at.1 - turned.1).abs() < 1e-5,
"{:?}",
out.slots[0].at
);
}
}