using System.Numerics; namespace AcDream.Core.World; public sealed class WorldEntity { public required uint Id { get; init; } /// /// Server-assigned GUID (from CreateObject). Zero for dat-hydrated /// scenery/static entities that don't come from the server. /// Used by GpuWorldState for persistent-entity rescue on landblock unload. /// public uint ServerGuid { get; init; } public required uint SourceGfxObjOrSetupId { get; init; } /// /// World-space position. Settable so Phase 6.7 position-update events /// can reseat an existing entity without rebuilding its meshes. /// public required Vector3 Position { get; set; } /// Settable for the same reason as . public required Quaternion Rotation { get; set; } /// /// Per-part mesh references with their root-relative transforms. /// Mutable so the animation tick can replace it each frame for /// entities that play a cycle (Phase 6.4); static entities set it /// once at hydration and never touch it again. /// public required IReadOnlyList MeshRefs { get; set; } /// /// Optional per-entity palette override (server-specified base + /// subpalette overlays). When non-null, applies to every palette- /// indexed texture on this entity. Used for character skin/hair /// colors, creature recolors (e.g. stone-colored drudge statue), /// and team colors. Non-palette-indexed textures ignore this field. /// public PaletteOverride? PaletteOverride { get; init; } /// /// EnvCell or outdoor cell ID that owns this entity (room geometry, static /// object, or live object inside/outside a cell). /// the cell). Used by portal visibility to filter interior entities — only /// entities whose ParentCellId appears in the visible set are rendered. /// Null for outdoor dat scenery/building stabs or unresolved live entities. /// public uint? ParentCellId { get; set; } /// /// True when this entity originates from LandBlockInfo.Buildings[] /// (the dat array that carries building shells: cottage walls, smithy walls, /// inn walls — every solid building enclosure). False for entities from /// LandBlockInfo.Objects[] (rocks, fences, lampposts, tree clusters — /// outdoor scenery placeholders). The two arrays are conflated through /// hydration today but the dat itself carries the distinction; retail /// (CLandBlock::init_buildings) and WorldBuilder /// (SceneryInstance.IsBuilding) both preserve it. /// /// /// Read at draw time by WbDrawDispatcher's IndoorPass /// partition so building shells can render when the camera is inside their /// own building (they ARE the indoor walls), not stencil-gated as outdoor /// scenery would be. /// /// public bool IsBuildingShell { get; init; } /// /// Dat-derived EnvCell anchor for a building shell. Building shells are /// top-level landblock stabs, so they do not have a real ParentCellId, but /// the LandBlockInfo.Buildings[] portal list names cells owned by the same /// building. The indoor renderer uses this anchor only for draw scoping: /// a shell renders in IndoorPass when its anchor belongs to the camera /// building's EnvCell set. Collision still treats the shell as an outdoor /// stab unless ParentCellId is explicitly set. /// public uint? BuildingShellAnchorCellId { get; init; } /// /// Uniform scale applied to this entity's mesh by the scenery pipeline. /// For scenery objects this is spawn.Scale (typically 0.8–1.3). For stabs /// and interior static objects this is 1.0 (no scaling). /// /// Used by the collision registration path to scale CylSphere / Sphere / /// Setup.Radius shapes so they match the visually-scaled mesh. Without /// this, scaled scenery has a collision cylinder that's smaller than the /// visible trunk, producing "partial passthrough" bugs. /// public float Scale { get; init; } = 1.0f; /// /// Server-sent part-swap overrides from AnimPartChange. Each entry /// replaces a Setup part's GfxObj with an alternate model (clothing, weapons, /// helmets). Carried on the entity so EntitySpawnAdapter can populate /// AnimatedEntityState's override map at spawn time. Empty for atlas- /// tier entities. /// public IReadOnlyList PartOverrides { get; init; } = Array.Empty(); /// /// Bitmask of hidden Setup parts. Bit i set hides part i at /// draw time. Sourced from the server's CreateObject record when /// present. Zero (no parts hidden) is the default. /// public ulong HiddenPartsMask { get; init; } // Per Phase A.5 spec §4.6 Change #2 — cache per-entity AABB so the // dispatcher's frustum cull is a memory read, not a per-frame recompute. // AabbDirty starts true so the dispatcher calls RefreshAabb on first read // (AabbMin/AabbMax are Vector3.Zero until refreshed). public Vector3 AabbMin { get; private set; } public Vector3 AabbMax { get; private set; } public bool AabbDirty { get; private set; } = true; /// /// Root-local geometry bounds: the union over MeshRefs of each part's dat /// vertex AABB transformed by its part transform (see /// Meshing.LocalBoundsAccumulator). Set at hydration from the same /// vertex data that gets drawn — the every-case fix for the "#119 bounds /// must cover the mesh" class. When absent (HasLocalBounds false), the /// part-offset heuristic below is the fallback. /// public Vector3 LocalBoundMin { get; private set; } public Vector3 LocalBoundMax { get; private set; } public bool HasLocalBounds { get; private set; } public void SetLocalBounds(Vector3 min, Vector3 max) { LocalBoundMin = min; LocalBoundMax = max; HasLocalBounds = true; AabbDirty = true; } private const float DefaultAabbRadius = 5.0f; public void RefreshAabb() { var p = Position; // #119 follow-up (2026-06-11): the box must cover the MESH, not just the // anchor. BOTH visibility gates derive from this box: the dispatcher's // per-entity frustum cull (WbDrawDispatcher.WalkEntitiesInto) and the // viewcone sphere (RetailPViewRenderer.EntitySphere = this box's // bounding sphere). The original fixed ±5 m anchor box dropped the AAB3 // tower staircase (parts spiralling 15 m above the anchor) whenever the // gaze left the anchor's neighborhood — stairs visible looking down, // gone looking up. // // Preferred path: dat-vertex-derived root-local bounds (SetLocalBounds // at hydration), rotated into world axes — re-boxing the 8 rotated // corners contains the rotated contents, so this is correct for EVERY // shape including a single tall part at identity transform (which the // offset heuristic below cannot see). DefaultAabbRadius stays as a // margin: it absorbs animated-pose drift (MeshRefs are swapped per // frame for animated entities while local bounds are rest-pose) and // keeps small objects at their historical box size. if (HasLocalBounds) { Vector3 lo = LocalBoundMin, hi = LocalBoundMax; var rot = Rotation; Vector3 min = default, max = default; for (int c = 0; c < 8; c++) { var corner = new Vector3( (c & 1) == 0 ? lo.X : hi.X, (c & 2) == 0 ? lo.Y : hi.Y, (c & 4) == 0 ? lo.Z : hi.Z); var t = Vector3.Transform(corner, rot); if (c == 0) { min = max = t; } else { min = Vector3.Min(min, t); max = Vector3.Max(max, t); } } AabbMin = p + min - new Vector3(DefaultAabbRadius); AabbMax = p + max + new Vector3(DefaultAabbRadius); AabbDirty = false; return; } // Fallback (no hydration bounds — e.g. tests, minimal fixtures): anchor // box expanded by the largest part-translation magnitude. Rotation- // invariant; covers multi-part spreads but NOT a single part whose mesh // extends >5 m from its own origin — which is why hydrated entities use // the vertex-derived path above. float radius = DefaultAabbRadius; var refs = MeshRefs; if (refs is not null) { float maxOffset = 0f; for (int i = 0; i < refs.Count; i++) { float len = refs[i].PartTransform.Translation.Length(); if (len > maxOffset) maxOffset = len; } radius += maxOffset; } AabbMin = new Vector3(p.X - radius, p.Y - radius, p.Z - radius); AabbMax = new Vector3(p.X + radius, p.Y + radius, p.Z + radius); AabbDirty = false; } public void SetPosition(Vector3 pos) { Position = pos; AabbDirty = true; } } /// /// Lightweight value type for a server-sent AnimPartChange (part index /// → replacement GfxObj id). Decouples WorldEntity (Core) from the /// network-layer CreateObject.AnimPartChange type. /// public readonly record struct PartOverride(byte PartIndex, uint GfxObjId);