Skip to main content

gizmo_core/
component.rs

1//! What makes a type a component, and where its rows are stored.
2//!
3//! [`Component`] is the trait every stored type implements — usually through the
4//! `impl_component!` macro rather than by hand — and [`StorageType`] chooses between the
5//! archetype table (dense, fast to iterate, the default) and a sparse set (cheap to add and
6//! remove on a small fraction of entities).
7//!
8//! The storage choice is not cosmetic: it decides which query operands are legal. Table
9//! storage backs the chunked/contiguous iteration paths; sparse-set components cannot be
10//! served as slices and are rejected — or panic — there.
11use std::any::Any;
12
13/// Which of the world's two backing stores holds the data of a component type.
14///
15/// The choice is a property of the *type*, not of an entity: it comes from
16/// [`Component::storage_type`], which must return the same answer on every call — see there for
17/// what an inconsistent impl breaks. The two stores are disjoint: a component lives in one of
18/// them, never both.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum StorageType {
21    /// Inside the archetype, as a contiguous column with one row per entity.
22    ///
23    /// Iteration is a linear scan over that column, and this is the only storage a chunked
24    /// query can hand out as a `&[T]` slice. The price is structural churn: adding or removing
25    /// a table component migrates the entity into a different archetype, which copies all of
26    /// its *other* components into a fresh row and swap-removes the old one (so an unrelated
27    /// entity changes row as a side effect).
28    ///
29    /// The default, and the right answer unless the component is added and removed far more
30    /// often than it is read.
31    Table,
32    /// Outside the archetype, in one per-type sparse set keyed by raw entity id.
33    ///
34    /// Adding or removing costs no archetype migration, which is what makes this suitable for
35    /// churny short-lived tags. In exchange: every access is an indirection through the
36    /// id → row table; archetype-level query matching cannot narrow anything, so
37    /// a `With`/`Without` on a sparse component matches *every* archetype and degrades into a
38    /// per-row presence test (unlike the table case, where the archetype test alone settles it);
39    /// and asking a query for a contiguous slice (`iter_chunks`) panics rather than degrading.
40    ///
41    /// Not every code path supports it — the bundle fast path writes archetype columns only,
42    /// so a bundle containing a sparse component is routed component-by-component instead
43    /// (see [`Bundle::apply`]).
44    SparseSet,
45}
46
47/// Data that can be attached to an entity.
48///
49/// There is no derive macro in this workspace; write the impl by hand or use
50/// [`impl_component!`](crate::impl_component). Implementing it is the only registration a type
51/// needs — the world records a component's runtime metadata the first time it sees the type.
52///
53/// The supertraits are load-bearing rather than decorative. `'static` because a component's
54/// identity everywhere in the ECS is its `TypeId`, so two distinct components can never share
55/// a type and a component can never borrow. `Send + Sync` because component storage is shared
56/// across worker threads by parallel query iteration. `Clone` because the storage layer records
57/// a clone thunk for every component type at registration; that thunk is what entity cloning
58/// (prefab splicing) uses, and a type that cannot clone cannot be a component.
59///
60/// Zero-sized components are legal and cost no allocation; they are the usual shape for
61/// markers such as [`IsHidden`].
62pub trait Component: 'static + Any + Send + Sync + Clone {
63    /// Where instances of this type are stored; see [`StorageType`].
64    ///
65    /// Answered by the type, not by an instance, so it must be a constant — the value is
66    /// captured into the world's component metadata the first time the type is registered, and
67    /// an impl that returned different values on different calls would leave the storage and
68    /// the metadata disagreeing about where the data lives.
69    ///
70    /// Defaults to [`StorageType::Table`].
71    fn storage_type() -> StorageType {
72        StorageType::Table
73    }
74}
75
76/// Writes an empty [`Component`] impl for one or more types, optionally choosing their
77/// [`StorageType`].
78///
79/// ```
80/// # #[derive(Clone)] struct Position; #[derive(Clone)] struct Velocity;
81/// # #[derive(Clone)] struct Frozen;   #[derive(Clone)] struct Stunned;
82/// // Neither is in the prelude: `StorageType` has to be nameable in *your* scope for the
83/// // `; $storage` argument below, and `Component` for `storage_type()`.
84/// use gizmo_core::component::{Component, StorageType};
85/// use gizmo_core::impl_component;
86///
87/// impl_component!(Position, Velocity);                       // default: Table storage
88/// impl_component!(Frozen, Stunned; StorageType::SparseSet);  // explicit storage
89///
90/// assert_eq!(Position::storage_type(), StorageType::Table);
91/// assert_eq!(Frozen::storage_type(), StorageType::SparseSet);
92/// ```
93///
94/// The macro only writes the impl: the types must already satisfy `Component`'s supertraits
95/// (`'static + Send + Sync + Clone`), and the expansion is an ordinary trait impl, so the usual
96/// orphan rule applies — outside gizmo-core itself, only types local to the invoking crate can
97/// be passed.
98///
99/// The `; $storage` argument is an expression expanded in the *caller's* scope, so whatever
100/// path it names (`StorageType::SparseSet`, `gizmo_core::component::StorageType::SparseSet`, …)
101/// has to resolve there. Only the storage-less form accepts a trailing comma after the last
102/// type; `impl_component!(A, B,; …)` does not parse.
103///
104/// `#[macro_export]` puts it at the crate root: `gizmo_core::impl_component!`.
105#[macro_export]
106macro_rules! impl_component {
107    ($($t:ty),+ $(,)?) => {
108        $(
109            impl $crate::Component for $t {}
110        )+
111    };
112    ($($t:ty),+ ; $storage:expr) => {
113        $(
114            impl $crate::Component for $t {
115                fn storage_type() -> $crate::component::StorageType {
116                    $storage
117                }
118            }
119        )+
120    };
121}
122
123// --- Hiyerarşi (Scene Graph) Bileşenleri ---
124/// Up-link from a child to its parent, carrying the parent's raw
125/// [`Entity::id`](crate::Entity::id) — a slot index with the generation stripped off.
126///
127/// Because the generation is gone the value cannot distinguish a live parent from a recycled
128/// id, and nothing revalidates it when the parent is despawned. Resolve it through
129/// [`World::entity`](crate::World::entity), which returns `None` for an id that is dead or has
130/// no storage, instead of trusting the number.
131///
132/// This is only half of a link: [`Children`] on the parent is the other half. Nothing
133/// synchronises the two halves automatically, so adding or editing this component directly
134/// leaves the parent's `Children` list stale.
135#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
136pub struct Parent(pub u32);
137
138/// Down-link from a parent to its children: raw entity ids (no generations), in the order they
139/// were attached.
140///
141/// The order is stable and meaningful. [`HierarchyExt::add_child`](crate::HierarchyExt::add_child)
142/// appends and skips ids already in the list, and `remove_child` retains, so iteration over a
143/// `Children` list is deterministic across a run — which is what lets hierarchy walks be
144/// replayed. Presence of this component is also what marks an archetype as a candidate for
145/// [`World::sort_archetype_hierarchy`](crate::World::sort_archetype_hierarchy), which permutes
146/// rows to put parents and children next to each other.
147///
148/// Entries are not validated: a child despawned outside `HierarchyExt` leaves a dangling id.
149/// Duplicates and cycles are impossible through `HierarchyExt` (it rejects self-parenting and
150/// any reparent that would close a loop) but perfectly possible via direct component writes or
151/// a hand-edited scene file, so `despawn_recursive` carries a visited set rather than assuming
152/// the graph is acyclic.
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
154pub struct Children(pub Vec<u32>);
155
156/// Human-readable label for an entity — for editor lists, logs and scene files.
157///
158/// Purely descriptive: nothing enforces uniqueness, nothing indexes it, and gizmo-core itself
159/// never reads it. Two entities may hold the same name, and an absent `EntityName` is normal
160/// (most entities never get one).
161#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
162pub struct EntityName(pub String);
163
164impl EntityName {
165    /// Allocates an owned copy of `name`. The field is public, so an already-owned `String` can
166    /// be moved in as `EntityName(s)` instead of paying for a second allocation here.
167    ///
168    /// No validation and no normalisation: empty, blank and already-used names are all accepted.
169    /// That matters because the derived `PartialEq` compares the stored text byte for byte —
170    /// `"Cube"` and `"cube "` are different labels to anything that searches by name.
171    pub fn new(name: &str) -> Self {
172        Self(name.to_string())
173    }
174}
175
176/// Zero-sized marker meaning "do not display this entity".
177///
178/// It carries no data, so visibility is binary and expressed structurally: hide with
179/// `add_component(e, IsHidden)`, show with `remove_component::<IsHidden>(e)`. gizmo-core
180/// attaches no behaviour to it whatsoever. A hidden entity is still alive and still visited by
181/// every query that does not explicitly exclude it.
182#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
183pub struct IsHidden;
184
185/// Zero-sized marker meaning "this entity is the editor's own tooling, not scene content":
186/// grids, light icons, selection boxes, handles.
187///
188/// # Why this is in the ECS floor and not in the editor
189///
190/// Because four unrelated layers need the answer and only this one is below all of them: the
191/// hierarchy panel hides these rows, the studio's game view refuses to draw them, the windowed
192/// app's editor runtime skips them, and — the one that matters most — [`crate`]'s sibling
193/// `gizmo-scene` leaves them out of a saved scene. That last consumer cannot see a renderer
194/// component: scene sits beside the renderer in the graph, not above it.
195///
196/// Until this existed, all four asked the same question by **string prefix on the entity name**
197/// (`"Editor "` / `"Highlight Box"`), written out four times. That works only by convention and
198/// fails in a way nobody would debug quickly: an office scene with a desk named "Editor Desk"
199/// loses it from the hierarchy and from every save.
200///
201/// The name rule is still honoured — see [`is_editor_only`] — because scenes saved before this
202/// component existed carry names and not markers.
203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
204pub struct EditorOnly;
205
206/// Is this entity the editor's own tooling rather than scene content?
207///
208/// The single place that decides. Callers pass whatever they have — a marker lookup, a name, or
209/// both — because the four call sites reach the world differently and a shared signature that
210/// takes `&World` would force three of them to look up something they already hold.
211///
212/// The legacy half (the name rule) is a **transition**, not a design: it exists so that scenes
213/// written before [`EditorOnly`] still round-trip. New tooling entities should carry the marker
214/// and need no particular name.
215#[inline]
216pub fn is_editor_only(has_marker: bool, name: Option<&str>) -> bool {
217    has_marker || name.is_some_and(|n| n.starts_with("Editor ") || n == "Highlight Box")
218}
219
220/// Zero-sized marker meaning "soft-deleted": the entity is still alive with its id, handles and
221/// components intact, but is meant to be skipped by processing.
222///
223/// gizmo-core never acts on it. It is a convention for the layers above, which exclude it with
224/// `Without<IsDeleted>` — the rigid-body physics systems do exactly that — and later despawn
225/// the marked entities for real. Because nothing is destroyed when the marker is added,
226/// removing it again restores the entity exactly; that reversibility is the point of the flag.
227#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
228pub struct IsDeleted;
229
230/// A request to populate this entity from a named prefab, left on the entity until something
231/// fulfils it.
232///
233/// The string is an opaque key: gizmo-core neither resolves nor validates it. No system in this
234/// workspace consumes the component — the Lua scripting bridge is its only in-tree producer —
235/// so it does nothing unless the application runs its own resolver, which must also remove the
236/// component afterwards, since nothing clears it automatically.
237#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
238pub struct PrefabRequest(pub String);
239
240impl PrefabRequest {
241    /// Stores an owned copy of `name` as the request key.
242    ///
243    /// Infallible and unvalidated: gizmo-core holds no prefab catalogue, so an empty or
244    /// misspelled key is indistinguishable here from a good one and can only fail later,
245    /// wherever the application resolves it.
246    pub fn new(name: &str) -> Self {
247        Self(name.to_string())
248    }
249    /// The request key, borrowed from the component — no allocation, no copy.
250    ///
251    /// May be empty, and is never checked against anything (see
252    /// [`new`](PrefabRequest::new)). The tuple field is public, so this is a reading
253    /// convenience rather than encapsulation: `req.0` reaches the same `String` and can
254    /// replace it.
255    pub fn name(&self) -> &str {
256        &self.0
257    }
258}
259
260impl std::fmt::Display for EntityName {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        write!(f, "{}", self.0)
263    }
264}
265
266/// Renderer-independent request for a mesh, as an asset key that the renderer's asset-loading
267/// pass turns into a GPU `Mesh` component.
268///
269/// The key is not simply a file path. The loader recognises the built-in primitives
270/// `"standard_cube"`, `"inverted_cube"`, `"plane"`, `"sphere"` and `"sprite_quad"`, the
271/// `"gltf_mesh_<file>.glb…"` / `"gltf_mesh_<file>.gltf…"` form for a mesh inside a glTF scene,
272/// and `"obj:<path>"`; anything else is treated as an OBJ path. Nothing validates the key when
273/// the component is attached, and a key that fails to load does not fail the frame: the entity
274/// is given a stand-in mesh and the failure is only logged.
275///
276/// Upload happens only while the entity has *no* `Mesh` yet, so this is a one-shot request:
277/// editing the string afterwards changes nothing until the `Mesh` component is removed.
278/// Keeping the key on the entity is also what lets a scene be saved back out.
279#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
280pub struct MeshSource(pub String);
281
282/// Renderer-independent material description, converted into a GPU `Material` by the renderer's
283/// asset-loading pass.
284///
285/// Like [`MeshSource`] this is consumed once — the conversion runs only while the entity has no
286/// `Material` component, so later edits to these fields do not reach the GPU material.
287///
288/// The numeric fields are carried through verbatim: nothing here clamps, normalises or
289/// colour-converts them, so the ranges named below are conventions rather than guarantees.
290#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
291pub struct MaterialSource {
292    /// Base colour and opacity as `[r, g, b, a]`, conventionally `0.0..=1.0` per channel.
293    ///
294    /// The fourth channel is the only opacity control on this struct — there is no separate
295    /// alpha, blend-mode or cutoff field. It is also the one channel group that has a texture
296    /// counterpart: [`texture_source`](Self::texture_source) is an *albedo* map, and no other
297    /// field here can be driven by a texture.
298    pub albedo: [f32; 4],
299    /// Microfacet roughness: `0.0` is a perfect mirror, `1.0` fully diffuse.
300    ///
301    /// A shading parameter, not a shading mode — which mode runs is decided solely by
302    /// [`unlit`](Self::unlit), and writing a roughness never changes it.
303    pub roughness: f32,
304    /// Metalness: `0.0` for a dielectric, `1.0` for a metal.
305    ///
306    /// Unlike [`roughness`](Self::roughness) this is conceptually two-valued rather than a
307    /// continuum — the two ends are the physical materials, and an intermediate number describes
308    /// a blend between them, which is normally only wanted where a texture crosses from one to
309    /// the other.
310    pub metallic: f32,
311    /// Shading mode selector, despite the name and the type: it is a *tri-state* float, not a
312    /// boolean.
313    ///
314    /// The renderer thresholds it — above `1.5` the material becomes a skybox, above `0.5` it
315    /// becomes unlit, and anything else (including `0.0`) is lit PBR. Values in between behave
316    /// like the lower bucket, and negative values are lit like zero.
317    ///
318    /// `MaterialSource` derives no `Default`, so there is no `..Default::default()` shorthand:
319    /// every literal must state this field, and `0.0` is the lit-PBR choice.
320    pub unlit: f32,
321    /// Path of the albedo texture to load, or `None` for an untextured material.
322    ///
323    /// `None` and a failed load are handled the same way — the material falls back to the
324    /// shared default white texture — except that a failure also logs a warning, so a
325    /// mistyped path renders as plain white rather than failing loudly.
326    pub texture_source: Option<String>,
327}
328
329impl_component!(Parent, Children, EntityName, IsHidden, EditorOnly, PrefabRequest, IsDeleted, MeshSource, MaterialSource);
330
331// ============================================================
332//  Bundle Trait
333// ============================================================
334
335/// A group of components that can be attached to an entity as one unit.
336///
337/// Implemented blanket-wise for every [`Component`] (a lone component is a one-element bundle)
338/// and for tuples of bundles up to 16 elements, which nest freely — `(A, (B, C))` is a bundle.
339///
340/// There are two ways into the world and an implementor owns both:
341/// [`apply`](Bundle::apply) inserts the components one at a time through the world, and
342/// [`write_to_archetype`](Bundle::write_to_archetype) blits them directly into archetype
343/// columns. They are not interchangeable — the second cannot store `SparseSet` components at
344/// all — and the caller picks: `World::spawn_bundle` always goes through `apply`;
345/// `World::add_bundle` writes the archetype directly; `World::spawn_batch` spawns its first
346/// entity through `apply` to discover the archetype and appends the rest directly. Whenever
347/// [`get_infos`](Bundle::get_infos) declares a `SparseSet` member, the direct paths give up and
348/// fall back to `apply`.
349///
350/// Since `apply` has a do-nothing default, a hand-written bundle that implements only
351/// `write_to_archetype` compiles and then silently attaches nothing wherever the `apply` path
352/// is used. Implement both.
353pub trait Bundle {
354    /// Runtime metadata for every component this bundle will write, in the order it writes
355    /// them.
356    ///
357    /// A property of the type — it takes no `self` and is called before any data moves — so it
358    /// must describe exactly what `apply`/`write_to_archetype` produce. The world relies on it
359    /// twice: to work out the destination archetype, and to spot `SparseSet` members that make
360    /// the fast path unusable. Nested bundles simply concatenate their infos, and repeated
361    /// component types are *not* deduplicated here; the world folds duplicates away when it
362    /// builds the archetype's sorted type set, but a duplicate still means the same column is
363    /// written twice.
364    fn get_infos() -> Vec<crate::archetype::ComponentInfo>;
365    /// Moves the bundle's components straight into `arch`'s columns at `row`, bypassing the
366    /// world.
367    ///
368    /// The fast path, and a narrow one: it can only reach `Table`-storage components, it
369    /// updates no entity location and fires no hooks, and it takes `arch` as given rather than
370    /// choosing it — the caller is responsible for the row belonging to the right entity in the
371    /// right archetype.
372    ///
373    /// Each component is appended when its column is still short of `row`, and otherwise raw-
374    /// written into slot `row` — treating that slot as *uninitialised*, so an already-live value
375    /// there is overwritten without being dropped (a leak for anything owning an allocation).
376    /// Either way the row's ticks are reset to `tick` in both fields, i.e. it reads as freshly
377    /// added, not merely changed.
378    ///
379    /// # Safety
380    /// `arch` must contain the component columns that `Self::get_infos()` returns, and `_row`
381    /// must be a valid row reserved in this archetype. The data is copied raw; ownership is
382    /// transferred to the archetype.
383    unsafe fn write_to_archetype(self, arch: &mut crate::archetype::Archetype, _row: usize, tick: u32);
384    /// Attaches the bundle to an entity that already exists, component by component, through
385    /// `World::add_component`.
386    ///
387    /// The storage-agnostic route: each component reaches whichever store its
388    /// [`StorageType`] says, and the normal `on_add`/`on_set` hooks fire. It is the only route
389    /// that can place a `SparseSet` component, and the slow one — every table component the
390    /// entity does not already have migrates it to another archetype, so an n-component bundle
391    /// can pay n migrations where the direct path pays one.
392    ///
393    /// The default body does nothing at all. It exists so that a bundle which only supports the
394    /// archetype fast path still compiles; an implementor who forgets to override it gets an
395    /// entity with none of the components and no error.
396    fn apply(self, _world: &mut crate::world::World, _entity: crate::entity::Entity) where Self: Sized {}
397}
398
399/// A bundle with one extra component appended, as produced by [`BundleExt::with`].
400///
401/// Composition is purely type-level: `get_infos` lists `B`'s components followed by `C`, and
402/// `write_to_archetype` writes `B` first and `C` second. If `C` also occurs in `B` the later
403/// write wins by overwriting the slot in place — without dropping what was there, which leaks
404/// whatever that value owned. An archetype with no column for `C` (a `SparseSet` `C`, or simply
405/// the wrong archetype) is a bare `unwrap` panic, without the explanation the single-component
406/// path prints.
407///
408/// **Known limitation.** This type does not override [`Bundle::apply`], so it inherits the
409/// no-op default: passing a `DynamicBundle` to `World::spawn_bundle` — or to `World::add_bundle`
410/// when it contains a `SparseSet` component — produces an entity with *none* of the components,
411/// silently. Only the direct archetype path (`World::add_bundle` with all-table components)
412/// stores anything. Nothing in this workspace constructs a `DynamicBundle`; treat it as
413/// experimental until `apply` is implemented.
414pub struct DynamicBundle<B: Bundle, C: Component> {
415    /// The base bundle, written before `component` and listed first in `get_infos`.
416    pub bundle: B,
417    /// The appended component, written last — so on a type collision with `bundle` this is the
418    /// value that survives.
419    pub component: C,
420}
421
422impl<B: Bundle, C: Component> Bundle for DynamicBundle<B, C> {
423    fn get_infos() -> Vec<crate::archetype::ComponentInfo> {
424        let mut infos = B::get_infos();
425        infos.push(crate::archetype::ComponentInfo::of::<C>());
426        infos
427    }
428
429    unsafe fn write_to_archetype(self, arch: &mut crate::archetype::Archetype, row: usize, tick: u32) {
430        self.bundle.write_to_archetype(arch, row, tick);
431        let col = arch.get_column_mut(std::any::TypeId::of::<C>()).unwrap();
432        if col.len() <= row {
433            col.push_raw(&self.component as *const _ as *const u8, tick);
434            std::mem::forget(self.component);
435        } else {
436            let ptr = col.get_mut_ptr(row) as *mut C;
437            std::ptr::write(ptr, self.component);
438            *col.ticks_ptr_mut().add(row) = crate::archetype::ComponentTicks::new(tick);
439        }
440    }
441}
442
443/// Chaining sugar for growing a bundle one component at a time.
444///
445/// Blanket-implemented for every [`Bundle`], so `a.with(b).with(c)` type-checks for any
446/// components; the result is a nest of [`DynamicBundle`]s, whose limitations apply — read them
447/// before using this.
448pub trait BundleExt: Bundle + Sized {
449    /// Appends `component` to this bundle and returns the combined value.
450    ///
451    /// Pure value construction: nothing touches a world until the result is spawned or added,
452    /// and both operands are moved. It is additive, never a replacement — appending a component
453    /// type the bundle already carries produces a bundle listing that type twice rather than
454    /// substituting the earlier one.
455    fn with<C: Component>(self, component: C) -> DynamicBundle<Self, C> {
456        DynamicBundle { bundle: self, component }
457    }
458}
459
460impl<T: Bundle> BundleExt for T {}
461
462impl<T: Component> Bundle for T {
463    fn get_infos() -> Vec<crate::archetype::ComponentInfo> {
464        vec![crate::archetype::ComponentInfo::of::<T>()]
465    }
466
467    fn apply(self, world: &mut crate::world::World, entity: crate::entity::Entity) {
468        world.add_component(entity, self);
469    }
470
471    unsafe fn write_to_archetype(self, arch: &mut crate::archetype::Archetype, row: usize, tick: u32) {
472        let col = arch.get_column_mut(std::any::TypeId::of::<T>()).unwrap_or_else(|| {
473            panic!(
474                "Component column for `{}` missing in Archetype. The bundle fast-path \
475                 (write_to_archetype) only handles Table-storage components; SparseSet \
476                 components must be routed via World::add_component. spawn_batch already \
477                 falls back for sparse bundles — reaching here means another bundle path \
478                 wrote a sparse component into the archetype.",
479                std::any::type_name::<T>()
480            )
481        });
482        if col.len() <= row {
483            col.push_raw(&self as *const _ as *const u8, tick);
484            std::mem::forget(self);
485        } else {
486            let ptr = col.get_mut_ptr(row) as *mut T;
487            std::ptr::write(ptr, self);
488            *col.ticks_ptr_mut().add(row) = crate::archetype::ComponentTicks::new(tick);
489        }
490    }
491}
492
493macro_rules! impl_bundle_tuple {
494    ($($name:ident),*) => {
495        #[allow(non_snake_case)]
496        impl<$($name: crate::component::Bundle),*> Bundle for ($($name,)*) {
497            fn get_infos() -> Vec<crate::archetype::ComponentInfo> {
498                let mut infos = Vec::new();
499                $(
500                    infos.extend(<$name as crate::component::Bundle>::get_infos());
501                )*
502                infos
503            }
504
505            fn apply(self, world: &mut crate::world::World, entity: crate::entity::Entity) {
506                let ($($name,)*) = self;
507                $(
508                    $name.apply(world, entity);
509                )*
510            }
511
512            unsafe fn write_to_archetype(self, arch: &mut crate::archetype::Archetype, row: usize, tick: u32) {
513                let ($($name,)*) = self;
514                $(
515                    $name.write_to_archetype(arch, row, tick);
516                )*
517            }
518        }
519    };
520}
521
522impl_bundle_tuple!(A);
523impl_bundle_tuple!(A, B);
524impl_bundle_tuple!(A, B, C);
525impl_bundle_tuple!(A, B, C, D);
526impl_bundle_tuple!(A, B, C, D, E);
527impl_bundle_tuple!(A, B, C, D, E, F);
528impl_bundle_tuple!(A, B, C, D, E, F, G);
529impl_bundle_tuple!(A, B, C, D, E, F, G, H);
530impl_bundle_tuple!(A, B, C, D, E, F, G, H, I);
531impl_bundle_tuple!(A, B, C, D, E, F, G, H, I, J);
532impl_bundle_tuple!(A, B, C, D, E, F, G, H, I, J, K);
533impl_bundle_tuple!(A, B, C, D, E, F, G, H, I, J, K, L);
534impl_bundle_tuple!(A, B, C, D, E, F, G, H, I, J, K, L, M);
535impl_bundle_tuple!(A, B, C, D, E, F, G, H, I, J, K, L, M, N);
536impl_bundle_tuple!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O);
537impl_bundle_tuple!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P);
538
539
540#[cfg(test)]
541mod editor_only_tests {
542    use super::*;
543
544    /// The predicate itself, both halves.
545    #[test]
546    fn the_marker_wins_and_the_legacy_names_still_count() {
547        // The marker alone is enough — no name needed, which is the point of having it.
548        assert!(is_editor_only(true, None));
549        assert!(is_editor_only(true, Some("Enemy")));
550
551        // Legacy: scenes written before the marker existed carry only names.
552        assert!(is_editor_only(false, Some("Editor Grid")));
553        assert!(is_editor_only(false, Some("Editor Light Icon 1")));
554        assert!(is_editor_only(false, Some("Highlight Box")));
555
556        // Content stays content.
557        assert!(!is_editor_only(false, Some("Enemy")));
558        assert!(!is_editor_only(false, None));
559        assert!(
560            !is_editor_only(false, Some("Editor")),
561            "the legacy rule needs the trailing space; a scene object merely named \"Editor\" is \
562             not tooling"
563        );
564        assert!(
565            !is_editor_only(false, Some("My Editor Desk")),
566            "the prefix is anchored — only names that START with it"
567        );
568    }
569
570    /// The name rule's failure mode, stated so it is not mistaken for a design.
571    ///
572    /// A scene object legitimately named "Editor Desk" is invisible in the hierarchy and dropped
573    /// from every save. That is why the marker exists; the rule survives only for old scenes.
574    #[test]
575    fn the_legacy_name_rule_still_swallows_a_legitimately_named_object() {
576        assert!(
577            is_editor_only(false, Some("Editor Desk")),
578            "documented wart: this is what the marker is for"
579        );
580    }
581
582    /// Nothing may re-implement the rule.
583    ///
584    /// It lived in **eight** places — the hierarchy panel (twice), the windowed app's editor
585    /// runtime, two filters in `gizmo-scene`'s snapshot, one in its scene writer, the studio's
586    /// protected-entity set, its delete guard, its select-all shortcut and its play-mode hide.
587    /// Eight copies of one string comparison, and every one of them had to agree about the
588    /// trailing space.
589    #[test]
590    fn the_editor_only_rule_is_written_once() {
591        let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
592            .parent()
593            .and_then(|p| p.parent())
594            .expect("crates/gizmo-core sits two levels below the workspace root")
595            .to_path_buf();
596        if !workspace.join("crates/gizmo-studio").is_dir() {
597            return; // packaged crate
598        }
599
600        let mut sources = Vec::new();
601        collect_rs(&workspace.join("crates"), &mut sources);
602        collect_rs(&workspace.join("demo"), &mut sources);
603        assert!(sources.len() > 100, "source walk found only {} files", sources.len());
604
605        let this_file = std::path::Path::new(file!()).file_name().unwrap();
606        let mut offenders = Vec::new();
607        for path in &sources {
608            if path.file_name() == Some(this_file) {
609                continue;
610            }
611            let text = std::fs::read_to_string(path).unwrap_or_default();
612            // Production code only. A test that asserts "this got filtered out" names the strings
613            // on purpose and is checking the outcome, not re-deciding it — `gizmo-scene`'s save
614            // test does exactly that. Cutting at the first `#[cfg(test)]` is approximate and
615            // deliberately so: the cost of a miss is a test module that could re-implement the
616            // rule unnoticed, and a test module that did would be caught by its own assertions
617            // disagreeing with production.
618            let code = text.split("#[cfg(test)]").next().unwrap_or("");
619            for (i, line) in code.lines().enumerate() {
620                let t = line.trim_start();
621                if t.starts_with("//") || t.starts_with("///") {
622                    continue;
623                }
624                if line.contains("starts_with(\"Editor \")") || line.contains("== \"Highlight Box\"") {
625                    offenders.push(format!("{}:{}", path.display(), i + 1));
626                }
627            }
628        }
629        assert!(
630            offenders.is_empty(),
631            "the editor-only rule must be asked of `component::is_editor_only`, not re-written. \
632             Offenders:\n{}",
633            offenders.join("\n")
634        );
635    }
636
637    fn collect_rs(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
638        let Ok(entries) = std::fs::read_dir(dir) else { return };
639        for entry in entries.flatten() {
640            let path = entry.path();
641            if path.is_dir() {
642                if path.file_name().is_some_and(|n| n == "target") {
643                    continue;
644                }
645                collect_rs(&path, out);
646            } else if path.extension().is_some_and(|e| e == "rs") {
647                out.push(path);
648            }
649        }
650    }
651}