using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.World; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; namespace AcDream.Core.Physics; /// /// Pure-function builder that translates a into a list of /// s suitable for registration via /// . /// /// /// Walks (1) every CylSphere → Cylinder shape, (2) every Sphere ONLY when no /// CylSpheres are present (matches retail and the existing landblock-static /// convention at GameWindow.cs:6034), and (3) every Part whose GfxObj has a /// non-null PhysicsBSP → per-part BSP shape, with local transforms from /// PlacementFrames[Resting | Default | first available]. /// /// /// /// Retail anchor: CPhysicsObj::FindObjCollisions calls /// CPartArray::FindObjCollisions which iterates parts; each part's /// find_obj_collisions tests CylSpheres + GfxObj BSP. We emit one /// ShadowShape per part contribution so the existing FindObjCollisions /// iteration loop in tests each part independently. /// /// public static class ShadowShapeBuilder { /// /// Build the shape list for a Setup. /// /// The Setup to walk. /// The entity's overall scale factor; multiplies /// every radius, height, and local offset. /// Predicate: does the GfxObj with this id /// have a non-null PhysicsBSP? Production: id => cache.GetGfxObj(id)?.BSP?.Root is not null. /// #175: per-part pose override for the /// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the /// closed pose for doors). Retail collision tests each part's LIVE /// CPhysicsPart pose, which for an idle entity is the motion /// table's default state, NOT the Setup's placement frame — the two /// differ on e.g. the Facility Hub double door (Setup 0x02000C9D: /// placement poses the panels AJAR at yaw −150°/−30°, y −0.44 m; the /// closed pose is straight). Null / short lists fall back to the /// placement frame per part (entities with no motion table, and the /// CylSphere/Sphere shapes, are unaffected — retail poses those from /// the setup too). public static IReadOnlyList FromSetup( Setup setup, float entScale, Func hasPhysicsBsp, IReadOnlyList? partPoseOverride = null) { if (setup is null) throw new ArgumentNullException(nameof(setup)); if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); var result = new List(); // 1. CylSpheres — each becomes a Cylinder shape. foreach (var cyl in setup.CylSpheres) { if (cyl.Radius <= 0f) continue; float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f; result.Add(new ShadowShape( GfxObjId: 0u, LocalPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale, LocalRotation: Quaternion.Identity, Scale: entScale, CollisionType: ShadowCollisionType.Cylinder, Radius: cyl.Radius * entScale, CylHeight: baseHeight * entScale)); } // 2. Spheres — only when no CylSpheres (matches landblock-static convention // at GameWindow.cs:6034). Each becomes a true Sphere (no height clamping). // Retail anchor: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance // for the overlap check, unlike CCylSphere which clips to [low_pt, high_pt]. if (setup.CylSpheres.Count == 0) { foreach (var sph in setup.Spheres) { if (sph.Radius <= 0f) continue; result.Add(new ShadowShape( GfxObjId: 0u, LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale, LocalRotation: Quaternion.Identity, Scale: entScale, CollisionType: ShadowCollisionType.Sphere, Radius: sph.Radius * entScale, CylHeight: 0f)); } } // 3. Parts — one BSP shape per part with a non-null PhysicsBSP. // Pose priority per part: partPoseOverride (the motion-table // default-state pose, #175) → placement frame → identity. AnimationFrame? placementFrame = ResolvePlacementFrame(setup); for (int i = 0; i < setup.Parts.Count; i++) { uint gfxId = (uint)setup.Parts[i]; if (!hasPhysicsBsp(gfxId)) continue; Frame partFrame; if (partPoseOverride is not null && i < partPoseOverride.Count) partFrame = partPoseOverride[i]; else if (placementFrame is not null && i < placementFrame.Frames.Count) partFrame = placementFrame.Frames[i]; else partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; // BSP radius default; caller substitutes the real BoundingSphere.Radius // at registration time when available. Loose-but-safe broadphase value. float bspRadius = 2f * entScale; result.Add(new ShadowShape( GfxObjId: gfxId, LocalPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale, LocalRotation: partFrame.Orientation, Scale: entScale, CollisionType: ShadowCollisionType.BSP, Radius: bspRadius, CylHeight: 0f)); } return result; } /// /// #185: build BSP shapes for a landblock-baked multi-part entity (buildings, /// stair runs, fences, rock clusters) from its per-part s, /// for registration via /// under the entity's SINGLE unique id. /// /// /// Replaces the former per-part Register(entity.Id * 256u + partIndex) /// (GameWindow.cs) whose * 256u OVERFLOWED uint32 for class-prefixed /// landblock ids (0x40/0x80/0xC0…): the overflow dropped /// the prefix byte, so different-class entities sharing the low 24 bits /// collided on one shadow part-id and Register's deregister-then-insert /// silently overwrote one entity's collision geometry — the #185 "invisible /// wall half-way up the stairs" (rendered steps with no collision). /// /// /// /// Retail anchor: a multi-part object is one CPhysicsObj + CPartArray; /// CPhysicsObj::add_shadows_to_cells (0x00514ae0) → CPartArray::AddPartsShadow /// walks the part array under the single object — no synthetic per-part id. /// /// /// /// Each part's local transform comes from its /// (root-relative), decomposed to LocalPosition/LocalRotation/Scale; /// RegisterMultiPart reconstructs the world placement identically /// (entityWorldPos + rotate(LocalPosition, entityWorldRot)). Building /// shells are excluded — they collide via the per-LandCell building channel /// (CSortCell::find_collisions), not as shadow objects. /// /// /// The entity's per-part mesh references. /// True for LandBlockInfo.Buildings[] shells. /// Resolves a GfxObj id to its cached physics (BSP + /// bounding sphere). Production: id => cache.GetGfxObj(id). public static List FromLandblockBspParts( IReadOnlyList meshRefs, bool isBuildingShell, Func getGfxObj) { if (getGfxObj is null) throw new ArgumentNullException(nameof(getGfxObj)); var shapes = new List(); // Building shells collide via the building channel (retail), not shadow objects. if (isBuildingShell || meshRefs is null) return shapes; foreach (var meshRef in meshRefs) { var phys = getGfxObj(meshRef.GfxObjId); if (phys?.BSP?.Root is null) continue; // no physics BSP → nothing to collide // PartTransform is root-relative; decompose to local pos/rot/scale. if (!Matrix4x4.Decompose(meshRef.PartTransform, out var pScale, out var pRot, out var pPos)) { pScale = Vector3.One; pRot = Quaternion.Identity; pPos = new Vector3(meshRef.PartTransform.M41, meshRef.PartTransform.M42, meshRef.PartTransform.M43); } float partScale = pScale.X > 0f ? pScale.X : 1f; // AC objects are uniformly scaled float localRadius = phys.BoundingSphere?.Radius ?? 1f; shapes.Add(new ShadowShape( GfxObjId: meshRef.GfxObjId, LocalPosition: pPos, LocalRotation: pRot, Scale: partScale, CollisionType: ShadowCollisionType.BSP, Radius: localRadius * partScale, CylHeight: 0f)); } return shapes; } /// Resolve the placement frame in priority Resting → Default → /// first available. Mirrors SetupMesh.Flatten's convention. private static AnimationFrame? ResolvePlacementFrame(Setup setup) { if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) return resting; if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) return def; foreach (var kvp in setup.PlacementFrames) return kvp.Value; return null; } }