acdream/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
Erik 69a2ca0c6d refactor(world): extract live projection mechanics
Move appearance rebinding, collision construction, default-pose resolution, local shadow ownership, and exact leave-world presentation into focused owners. Preserve retail parent ordering with staged validation, committed recovery, recursive attached-subtree withdrawal, and retryable exact teardown across parent, pickup, position, unwield, and pose-loss edges.

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-21 16:17:03 +02:00

232 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Pure-function builder that translates a <see cref="Setup"/> into a list of
/// <see cref="ShadowShape"/>s suitable for registration via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>.
///
/// <para>
/// 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].
/// </para>
///
/// <para>
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> calls
/// <c>CPartArray::FindObjCollisions</c> which iterates parts; each part's
/// <c>find_obj_collisions</c> tests CylSpheres + GfxObj BSP. We emit one
/// ShadowShape per part contribution so the existing FindObjCollisions
/// iteration loop in <see cref="Transition"/> tests each part independently.
/// </para>
/// </summary>
public static class ShadowShapeBuilder
{
/// <summary>
/// Build the shape list for a Setup.
/// </summary>
/// <param name="setup">The Setup to walk.</param>
/// <param name="entScale">The entity's overall scale factor; multiplies
/// every radius, height, and local offset.</param>
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
/// <param name="partPoseOverride">#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
/// <c>CPhysicsPart</c> 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).</param>
/// <param name="effectivePartGfxObjIds">Current part identities after
/// retail <c>AnimPartChanged</c> processing. Collision keeps each Setup
/// index and pose, but reads PhysicsBSP from the installed replacement.
/// Null or short lists fall back to the Setup identity.</param>
public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup,
float entScale,
Func<uint, bool> hasPhysicsBsp,
IReadOnlyList<Frame>? partPoseOverride = null,
IReadOnlyList<uint>? effectivePartGfxObjIds = null)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
var result = new List<ShadowShape>();
// 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++)
{
// Retail CPhysicsPart::SetPart installs AnimPartChanged's current
// degrade array before CPartArray::FindObjCollisions reads it.
// Keep the stable Setup part index/pose, but source collision
// identity from that effective part when one was supplied.
uint gfxId = effectivePartGfxObjIds is not null
&& i < effectivePartGfxObjIds.Count
? effectivePartGfxObjIds[i]
: (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;
}
/// <summary>
/// #185: build BSP shapes for a landblock-baked multi-part entity (buildings,
/// stair runs, fences, rock clusters) from its per-part <see cref="MeshRef"/>s,
/// for registration via <see cref="ShadowObjectRegistry.RegisterMultiPart"/>
/// under the entity's SINGLE unique id.
///
/// <para>
/// Replaces the former per-part <c>Register(entity.Id * 256u + partIndex)</c>
/// (GameWindow.cs) whose <c>* 256u</c> OVERFLOWED uint32 for class-prefixed
/// landblock ids (<c>0x40</c>/<c>0x80</c>/<c>0xC0</c>…): the overflow dropped
/// the prefix byte, so different-class entities sharing the low 24 bits
/// collided on one shadow part-id and <c>Register</c>'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).
/// </para>
///
/// <para>
/// Retail anchor: a multi-part object is one <c>CPhysicsObj</c> + <c>CPartArray</c>;
/// <c>CPhysicsObj::add_shadows_to_cells</c> (0x00514ae0) → <c>CPartArray::AddPartsShadow</c>
/// walks the part array under the single object — no synthetic per-part id.
/// </para>
///
/// <para>
/// Each part's local transform comes from its <see cref="MeshRef.PartTransform"/>
/// (root-relative), decomposed to LocalPosition/LocalRotation/Scale;
/// <c>RegisterMultiPart</c> reconstructs the world placement identically
/// (<c>entityWorldPos + rotate(LocalPosition, entityWorldRot)</c>). Building
/// shells are excluded — they collide via the per-LandCell building channel
/// (<c>CSortCell::find_collisions</c>), not as shadow objects.
/// </para>
/// </summary>
/// <param name="meshRefs">The entity's per-part mesh references.</param>
/// <param name="isBuildingShell">True for <c>LandBlockInfo.Buildings[]</c> shells.</param>
/// <param name="getGfxObj">Resolves a GfxObj id to its cached physics (BSP +
/// bounding sphere). Production: <c>id => cache.GetGfxObj(id)</c>.</param>
public static List<ShadowShape> FromLandblockBspParts(
IReadOnlyList<MeshRef> meshRefs,
bool isBuildingShell,
Func<uint, GfxObjPhysics?> getGfxObj)
{
if (getGfxObj is null) throw new ArgumentNullException(nameof(getGfxObj));
var shapes = new List<ShadowShape>();
// 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;
}
/// <summary>Resolve the placement frame in priority Resting → Default →
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
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;
}
}