using System.Numerics; namespace AcDream.Core.Physics; /// /// One physics-BSP part's flood geometry, resolved as ONE value: the part /// GfxObj's physics-BSP root bounding sphere AND the axis-aligned box of its /// vertex array, both in the GfxObj's own unscaled frame. /// /// /// The pairing is the AP-156 invariant applied a second time. Retail's cell /// membership reads BOTH — CEnvCell::find_transit_cells @0x0052cae0 /// takes CGfxObj::physics_sphere ([gfxobj+0x74]) for its cheap /// portal-plane reject, and CLandCell::add_all_outside_cells @0x00533360 /// takes CPhysicsPart::GetBoundingBox @0x0050d600 /// (&gfxobj->gfx_bound_box) for the outdoor extent walk. A resolver /// that answered only one of the two would leave the other call site to /// synthesize a substitute, which is exactly how the sphere came to be placed /// at the part origin (AP-156) and how #334's outdoor rectangle came to be a /// fixed 3×3. /// /// /// /// / come from /// FlatGfxObjVisualBounds, which /// FlatCollisionAssetBuilder.FlattenGfxObj computes with /// PhysicsDataCache.ComputeVisualBounds(source.VertexArray) — the exact /// CGfxObj::init_end @0x00534200 computation (seed min=max=vertices[0], /// then BBox::AdjustBBox over every vertex of the render vertex array, /// which is also the array the physics polygons index into). /// /// public readonly record struct ShadowPartGeometry { private ShadowPartGeometry( FlatCollisionSphere sphere, Vector3 boxMin, Vector3 boxMax) { Sphere = sphere; BoxMin = boxMin; BoxMax = boxMax; } /// Retail CGfxObj::physics_sphere — the physics BSP root /// bounding sphere, origin included, unscaled. public FlatCollisionSphere Sphere { get; } /// Retail CGfxObj::gfx_bound_box.m_vMin, unscaled. public Vector3 BoxMin { get; } /// Retail CGfxObj::gfx_bound_box.m_vMax, unscaled. public Vector3 BoxMax { get; } /// /// Pairs the two. is the prepared /// package's FlatGfxObjVisualBounds; when it is absent (graph-only /// fixtures, and prepared assets baked without the field) the box falls /// back to the sphere's own axis-aligned bound, which contains every /// physics polygon vertex the sphere contains and keeps the substitution /// in the over-inclusive direction retail itself uses (§1.8 of /// docs/research/2026-08-06-334-contract.md). The fallback lives /// HERE so no call site can observe a half-populated value. /// public static ShadowPartGeometry Create( FlatCollisionSphere sphere, FlatGfxObjVisualBounds? visualBounds) { if (visualBounds is { } bounds) return new ShadowPartGeometry(sphere, bounds.Min, bounds.Max); var extent = new Vector3(sphere.Radius); return new ShadowPartGeometry( sphere, sphere.Origin - extent, sphere.Origin + extent); } } /// /// One physics-BSP part's world-placed bounding box — the per-part input to /// retail's outdoor extent walk. /// /// /// Retail's CLandCell::add_all_outside_cells @0x00533360 calls /// BBox::LocalToGlobal(part->gfxobj->gfx_bound_box, part->pos, /// cell0->pos) (@0x00533527) per part, so the box it divides by /// square_length is the part's authored box re-fit through the part's /// own placement. / are that /// authored box (entity-scaled); / /// are the part placement /// composes for the /// ShadowEntry rows, so the flood and the geometry can never disagree /// about where the part is. /// /// /// /// CONSTRUCTION IS BY FACTORY ONLY: min and max arrive together, from one /// , so no future producer can carry one and drop the /// other. /// /// public readonly record struct ShadowPartBox { private ShadowPartBox( Vector3 localMin, Vector3 localMax, Vector3 worldPosition, Quaternion worldRotation) { LocalMin = localMin; LocalMax = localMax; WorldPosition = worldPosition; WorldRotation = worldRotation; } /// Authored box minimum in the part's own frame, entity-scaled. public Vector3 LocalMin { get; } /// Authored box maximum in the part's own frame, entity-scaled. public Vector3 LocalMax { get; } /// The part's world placement — retail CPhysicsPart::pos. public Vector3 WorldPosition { get; } /// The part's world orientation — retail CPhysicsPart::pos. public Quaternion WorldRotation { get; } /// /// Composes the part's world placement exactly as /// composes the /// emitted ShadowEntry's. /// public static ShadowPartBox FromShape( in ShadowShape shape, Vector3 entityWorldPosition, Quaternion entityWorldRotation) => new( shape.LocalBoundsMin, shape.LocalBoundsMax, entityWorldPosition + Vector3.Transform(shape.LocalPosition, entityWorldRotation), entityWorldRotation * shape.LocalRotation); /// /// Retail BBox::LocalToGlobal @0x005b2120 — a proper EIGHT-CORNER /// re-fit, not a min/max transform: transform min, seed both /// corners from it, transform the other seven and AdjustBBox each. /// A rotated box therefore GROWS, conservatively, which is the direction /// retail deliberately errs in. /// /// World origin of the destination frame — /// retail's cell0->pos, i.e. the landblock the extent walk /// anchors on. public void RefitTo(Vector3 frameOrigin, out Vector3 min, out Vector3 max) { Vector3 offset = WorldPosition - frameOrigin; min = new Vector3(float.MaxValue); max = new Vector3(float.MinValue); for (int corner = 0; corner < 8; corner++) { var local = new Vector3( (corner & 1) == 0 ? LocalMin.X : LocalMax.X, (corner & 2) == 0 ? LocalMin.Y : LocalMax.Y, (corner & 4) == 0 ? LocalMin.Z : LocalMax.Z); Vector3 world = Vector3.Transform(local, WorldRotation) + offset; min = Vector3.Min(min, world); max = Vector3.Max(max, world); } } /// /// AP-159 / #335 (2026-08-07). Retail BBox::LocalToLocal /// @0x005b1e60 — the indoor sibling of 's /// BBox::LocalToGlobal. Differs only in that the DESTINATION /// frame carries its own rotation (a cell's own orientation), not just a /// translation offset: is the degenerate case of /// this method where the destination frame has no rotation (world axes). /// Same eight-corner transform-and-refit discipline — a rotated box /// grows, conservatively, matching retail's own direction. /// /// The destination frame's world-to-local /// transform — e.g. a cell's InverseWorldTransform. public void RefitToLocal(Matrix4x4 worldToLocal, out Vector3 min, out Vector3 max) { min = new Vector3(float.MaxValue); max = new Vector3(float.MinValue); for (int corner = 0; corner < 8; corner++) { var local = new Vector3( (corner & 1) == 0 ? LocalMin.X : LocalMax.X, (corner & 2) == 0 ? LocalMin.Y : LocalMax.Y, (corner & 4) == 0 ? LocalMin.Z : LocalMax.Z); Vector3 world = Vector3.Transform(local, WorldRotation) + WorldPosition; Vector3 dest = Vector3.Transform(world, worldToLocal); min = Vector3.Min(min, dest); max = Vector3.Max(max, dest); } } }