Skip to main content

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}