renew_scene/lib.rs
1//! Parenting: local placements composed into world placements.
2//!
3//! A scene node is an entity carrying a [`Local`] — where it sits relative to
4//! whatever it is attached to. Attachment is a second component, [`Parent`],
5//! holding a whole [`Entity`] handle. [`propagate`] reads those two stores and
6//! fills a third with [`Global`], the world placement, resolving every parent
7//! before its children.
8//!
9//! # Contract
10//!
11//! * **Deterministic.** For a hierarchy without loops, the output depends on
12//! the *shape* of that hierarchy and on nothing else — not on slot numbers,
13//! not on insertion history, not on how many times [`propagate`] has run.
14//! Two worlds built with different slot assignments but the same shape
15//! produce the same placements, bit for bit; the relabelling property test
16//! holds this.
17//!
18//! **A loop is the one exception, and it is exact rather than hedged.** Every
19//! node still gets a placement and the count still reports the loop. The cut
20//! falls on the loop member the climb reaches **last** — the one whose own
21//! parent is the member the climb entered the loop by — and *which* member
22//! that is follows from which node the pass seeded from, which is entity
23//! order. Relabel the world and a different member may be cut, so the
24//! placements inside a loop can differ between two worlds of the same shape.
25//! No ordering fixes this — a loop has no member a hierarchy can call first —
26//! so a caller who needs reproducible placements must not build one, and
27//! [`Propagated::cyclic`] is how they find out they did.
28//! * **Total.** Every *live* entity with a [`Local`] gets a [`Global`],
29//! including nodes whose parent died, nodes whose parent is not a scene node,
30//! and nodes inside a cycle. There is no live input for which a node is
31//! silently skipped; [`Propagated`] counts each category so a caller can
32//! notice. A **despawned** entity is not walked at all — see *Stale globals*.
33//! * **Exact rotation.** Angles compose by wrapping addition on a binary-angle
34//! integer, so a chain of rotations neither drifts nor accumulates error, at
35//! any depth. Translation composes in fixed point and saturates rather than
36//! wrapping; `renew_fixed::saturations()` counts it when it happens.
37//! * **Globals are derived, never authored.** [`Global`] has no public fields,
38//! and no public way to build one *from a placement* — no constructor taking
39//! a translation and a rotation, and no [`Default`]. The only values that
40//! exist are the ones [`propagate`] derived and [`Global::IDENTITY`], the
41//! world origin. That is what makes "a world placement is a function of the
42//! hierarchy" a property of the type rather than a habit a caller can forget.
43//!
44//! # Two mechanics that look like details and are not
45//!
46//! **A parent is a whole handle, not a slot.** [`Parent`] stores [`Entity`],
47//! generation included, and [`propagate`] checks it against [`Entities`]. Slots
48//! are recycled; a bare index would silently re-attach an orphan to whatever
49//! moved in next, which is a bug that reproduces perfectly and looks like a
50//! physics glitch.
51//!
52//! The check is against the [`Entities`] handed to [`propagate`], and generations
53//! are only unique within one allocator. A handle minted by a *different*
54//! `Entities` whose slot and generation both happen to match will be obeyed.
55//! Nothing in the engine hands out entities from two allocators into one world,
56//! and this crate does not defend against it.
57//!
58//! **Resolution order is not slot order.** `Entities::spawn` pops its free list
59//! newest-first, so a child can hold a *lower* slot than its parent. A single
60//! ascending pass would then compose that child against its parent's
61//! *previous-tick* placement — one frame of lag, on some entities, depending on
62//! spawn history. It is invisible to a determinism test, because it is
63//! perfectly reproducible. [`propagate`] therefore walks each node's ancestry
64//! upward first and composes on the way back down. `slot_order_does_not_decide`
65//! is the regression test.
66//!
67//! # Stale globals
68//!
69//! Despawning an entity does not remove its components — that is true of every
70//! store in this engine and scene is not special. A [`Global`] left behind by a
71//! despawned node is never *read* as anybody's parent, because the handle check
72//! rejects it first; it is only occupying a slot until the caller removes it.
73
74// The determinism rule a simulation crate lives under: no floating-point
75// arithmetic whose result can reach digested state. A placement composed with
76// a float would be reproducible on one machine and not across a fleet, which
77// is the failure the whole fixed-point stack exists to refuse. Denied here
78// rather than left to review — the lint covers operators only, so it is
79// necessary and not sufficient, but what it covers it covers with teeth.
80#![deny(
81 clippy::float_arithmetic,
82 clippy::print_stdout,
83 clippy::print_stderr,
84 missing_docs
85)]
86
87use renew_ecs::{Entities, Entity, Store};
88use renew_fixed::{Angle, Vec2};
89
90/// Where a node sits relative to its parent, or relative to the world when it
91/// has none.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub struct Local {
94 /// Offset from the parent's origin, in the parent's rotated frame.
95 pub translation: Vec2,
96 /// Rotation relative to the parent's.
97 pub rotation: Angle,
98}
99
100impl Local {
101 /// A placement built from its parts.
102 #[must_use]
103 pub const fn new(translation: Vec2, rotation: Angle) -> Self {
104 Self {
105 translation,
106 rotation,
107 }
108 }
109}
110
111/// What a node is attached to.
112///
113/// The whole handle, generation included — see the crate docs for why a slot
114/// would be a defect rather than an optimisation.
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub struct Parent(pub Entity);
117
118/// Where a node sits in the world.
119///
120/// Derived, never authored: no public fields, and no way to build one from a
121/// translation and a rotation. [`Global::IDENTITY`] is the single exception and
122/// names one value, the world origin — you can say "nowhere in particular", but
123/// you cannot say where something is. Everything else exists because
124/// [`propagate`] wrote it.
125///
126/// Deliberately not [`Default`]: a derived `Default` is a public constructor,
127/// and one silently reachable through every `unwrap_or_default` in every
128/// consumer, which is exactly how "derived, never authored" would stop being
129/// true without anybody deciding it should.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub struct Global {
132 translation: Vec2,
133 rotation: Angle,
134}
135
136impl Global {
137 /// The world origin, facing along the x axis.
138 ///
139 /// What a node with no usable parent composes against.
140 pub const IDENTITY: Self = Self {
141 translation: Vec2::ZERO,
142 rotation: Angle::ZERO,
143 };
144
145 /// Position in world space.
146 #[must_use]
147 pub const fn translation(self) -> Vec2 {
148 self.translation
149 }
150
151 /// Orientation in world space.
152 #[must_use]
153 pub const fn rotation(self) -> Angle {
154 self.rotation
155 }
156
157 /// A child's world placement, given its parent's.
158 ///
159 /// Rotation adds, wrapping and exact. Translation rotates into the
160 /// parent's frame before it adds, which is what makes a child orbit when
161 /// its parent turns instead of sliding.
162 fn compose(self, local: Local) -> Self {
163 Self {
164 translation: self.translation + local.translation.rotate(self.rotation),
165 rotation: self.rotation + local.rotation,
166 }
167 }
168}
169
170/// What a single [`propagate`] call did.
171///
172/// The three failure counts are diagnostics, not errors: every node in them
173/// still received a [`Global`]. They exist so a caller can assert `orphaned +
174/// cyclic == 0` in a test and find out at the point of the mistake rather than
175/// three frames later when something is drawn in the wrong place.
176///
177/// They count nodes composed **against the world origin** rather than against a
178/// parent — which is not the same as landing there, since such a node still
179/// sits wherever its own [`Local`] puts it. So they are not a partition of
180/// `nodes` and their sum is not it: an ordinary child composes against a parent
181/// that resolved, and belongs to none of the three. `roots + orphaned + cyclic`
182/// is the number of independent trees the pass found.
183#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
184pub struct Propagated {
185 /// Nodes given a world placement — every entity with a [`Local`].
186 pub nodes: u32,
187 /// Nodes with no [`Parent`] component, composed against the world.
188 pub roots: u32,
189 /// Nodes whose [`Parent`] names a despawned entity, or one that is not a
190 /// scene node. Composed as if they had no parent.
191 pub orphaned: u32,
192 /// Nodes whose parent chain closes a loop. One member of each loop is
193 /// composed as if it had no parent, so the rest can compose against it and
194 /// the pass terminates. See the crate docs: *which* member that is
195 /// depends on entity order, and it is the single case where two worlds of
196 /// the same shape can disagree.
197 pub cyclic: u32,
198}
199
200/// Reusable buffers for [`propagate`].
201///
202/// Capacity, not state: two calls with the same world produce the same result
203/// whatever this held beforehand. Owned by the caller so the pass can promise
204/// no steady-state allocation — hand it the same one every tick and it stops
205/// growing once the world stops.
206#[derive(Clone, Debug, Default)]
207pub struct Scratch {
208 marks: Vec<Mark>,
209 ancestry: Vec<u32>,
210}
211
212impl Scratch {
213 /// Empty. The first [`propagate`] sizes it.
214 #[must_use]
215 pub fn new() -> Self {
216 Self::default()
217 }
218
219 /// Sized up front, for a caller that would rather not allocate on the
220 /// first tick.
221 #[must_use]
222 pub fn with_capacity(entities: usize) -> Self {
223 Self {
224 marks: Vec::with_capacity(entities),
225 ancestry: Vec::with_capacity(entities),
226 }
227 }
228}
229
230/// How far a slot has got in the current call.
231#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
232enum Mark {
233 /// Not reached yet.
234 #[default]
235 Untouched,
236 /// On the path currently being climbed. Reaching one of these again is
237 /// what a cycle looks like from the inside.
238 Climbing,
239 /// Has its [`Global`].
240 Placed,
241}
242
243/// Compose every [`Local`] into a [`Global`], parents first.
244///
245/// Runs in time proportional to the number of scene nodes plus the highest
246/// occupied slot: each node's ancestry is climbed once across the whole call,
247/// not once per node. Allocates only while `scratch` or `globals` is still
248/// growing to fit the world.
249///
250/// The return value is the only report of a malformed hierarchy — a discarded
251/// [`Propagated`] turns a parent typo into a thing quietly drawn in the wrong
252/// place — so it is `#[must_use]`.
253///
254/// # Example
255///
256/// A hub turned a quarter turn, with a child one unit along its x axis. The
257/// child swings round to the y axis rather than staying put, which is the one
258/// thing a caller could not have got by adding two vectors — and the reason
259/// this crate exists rather than the composition living at each call site.
260///
261/// ```
262/// use renew_ecs::{Entities, Store};
263/// use renew_fixed::{Angle, Fixed, Vec2};
264/// use renew_scene::{Global, Local, Parent, Scratch, propagate};
265///
266/// let mut entities = Entities::new();
267/// let (mut parents, mut locals, mut globals) =
268/// (Store::default(), Store::default(), Store::default());
269///
270/// let hub = entities.spawn();
271/// locals.insert(hub.index(), Local::new(Vec2::ZERO, Angle::QUARTER));
272///
273/// let arm = entities.spawn();
274/// locals.insert(
275/// arm.index(),
276/// Local::new(Vec2::new(Fixed::ONE, Fixed::ZERO), Angle::ZERO),
277/// );
278/// parents.insert(arm.index(), Parent(hub));
279///
280/// let mut scratch = Scratch::new();
281/// let counts = propagate(&mut scratch, &entities, &parents, &locals, &mut globals);
282/// assert_eq!(counts.nodes, 2);
283///
284/// let placed: Global = *globals.get(arm.index()).expect("placed");
285/// assert_eq!(placed.translation(), Vec2::new(Fixed::ZERO, Fixed::ONE));
286/// assert_eq!(placed.rotation(), Angle::QUARTER);
287/// ```
288#[must_use]
289pub fn propagate(
290 scratch: &mut Scratch,
291 entities: &Entities,
292 parents: &Store<Parent>,
293 locals: &Store<Local>,
294 globals: &mut Store<Global>,
295) -> Propagated {
296 let Scratch { marks, ancestry } = scratch;
297 marks.clear();
298 marks.resize(entities.capacity(), Mark::Untouched);
299 ancestry.clear();
300
301 let mut counts = Propagated::default();
302
303 for entity in entities.iter() {
304 let slot = entity.index();
305 if !locals.contains(slot) || mark_of(marks, slot) != Mark::Untouched {
306 continue;
307 }
308
309 // Climb to the top of this node's ancestry, marking the path, and stop
310 // at the first ancestor that is already placed, already on the path
311 // (a cycle), or has no usable parent.
312 let mut cursor = slot;
313 loop {
314 set_mark(marks, cursor, Mark::Climbing);
315 ancestry.push(cursor);
316 match parent_slot(entities, parents, locals, cursor) {
317 Some(next) if mark_of(marks, next) == Mark::Untouched => cursor = next,
318 _ => break,
319 }
320 }
321
322 // Back down. The climb pushed deepest-first, so this walks the buffer
323 // in reverse — shallowest ancestor first, every parent placed before
324 // the child that composes against it. Reading it *forwards* is the
325 // previous-tick lag the crate docs describe, so the direction here is
326 // the whole correctness argument and not a style choice.
327 for &node in ancestry.iter().rev() {
328 // Both fallbacks below are unreachable and asserted rather than
329 // quietly taken: every slot in `ancestry` was admitted by a
330 // `locals.contains` check, and every slot marked `Placed` had a
331 // global written in this same loop. Substituting the identity for
332 // either would put a node at the world origin and call it an
333 // answer.
334 debug_assert!(locals.contains(node), "a climbed node always has a local");
335 let local = locals.get(node).copied().unwrap_or_default();
336 let base = match parent_slot(entities, parents, locals, node) {
337 Some(above) if mark_of(marks, above) == Mark::Placed => {
338 debug_assert!(globals.contains(above), "a placed node always has a global");
339 globals.get(above).copied().unwrap_or(Global::IDENTITY)
340 }
341 Some(_) => {
342 counts.cyclic = counts.cyclic.saturating_add(1);
343 Global::IDENTITY
344 }
345 None => {
346 if parents.contains(node) {
347 counts.orphaned = counts.orphaned.saturating_add(1);
348 } else {
349 counts.roots = counts.roots.saturating_add(1);
350 }
351 Global::IDENTITY
352 }
353 };
354 globals.insert(node, base.compose(local));
355 set_mark(marks, node, Mark::Placed);
356 counts.nodes = counts.nodes.saturating_add(1);
357 }
358 ancestry.clear();
359 }
360
361 counts
362}
363
364/// The slot this node composes against, or `None` when it composes against the
365/// world.
366///
367/// `None` covers three different situations that the caller distinguishes by
368/// asking whether a [`Parent`] component exists at all: no parent, a parent
369/// handle whose entity is gone, and a parent that carries no [`Local`] and so
370/// has no placement to compose against.
371///
372/// A node parented to *itself* is not one of them. It is a loop of length one
373/// and is reported as such, because the climb marks a node before asking what
374/// is above it. Answering `None` here instead would file the same node under
375/// `orphaned`, which points at a different mistake with a different fix.
376fn parent_slot(
377 entities: &Entities,
378 parents: &Store<Parent>,
379 locals: &Store<Local>,
380 slot: u32,
381) -> Option<u32> {
382 let Parent(handle) = *parents.get(slot)?;
383 if !entities.is_alive(handle) {
384 return None;
385 }
386 let above = handle.index();
387 if !locals.contains(above) {
388 return None;
389 }
390 Some(above)
391}
392
393fn mark_of(marks: &[Mark], slot: u32) -> Mark {
394 usize::try_from(slot)
395 .ok()
396 .and_then(|slot| marks.get(slot))
397 .copied()
398 .unwrap_or_default()
399}
400
401/// Every slot reaching here indexes within `marks`: it is sized to the entity
402/// allocator's capacity before the walk begins, and slots arrive either from a
403/// live entity or from a parent handle already checked against `Entities` —
404/// both below that capacity.
405///
406/// Asserted rather than clamped, because a slot that fell outside would make
407/// this a silent no-op, and a mark that never gets set makes the climb's cycle
408/// detection blind: the loop below would stop terminating rather than answer
409/// wrongly, which is the worse of the two failures.
410fn set_mark(marks: &mut [Mark], slot: u32, mark: Mark) {
411 debug_assert!(
412 usize::try_from(slot).is_ok_and(|slot| slot < marks.len()),
413 "slot {slot} is outside the range the walk marked"
414 );
415 if let Some(entry) = usize::try_from(slot)
416 .ok()
417 .and_then(|slot| marks.get_mut(slot))
418 {
419 *entry = mark;
420 }
421}