From f3bc15ed9d7b03a214ff862813ef079ab83841cf Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 23:54:33 +0200 Subject: [PATCH] feat(vfx #C.1.5b): SetupPartTransforms helper for per-part anchor transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Computes Matrix4x4 per Setup part by walking PlacementFrames[Resting] → [Default] → first-available, matching SetupMesh.Flatten's priority. Foundation for #56 fix: ParticleHookSink will use these to apply each CreateParticleHook's PartIndex-relative offset to the right mesh part. 4 xUnit tests cover Resting-over-Default preference, Default fallback, empty-PlacementFrames returns empty, DefaultScale application. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Meshing/SetupPartTransforms.cs | 76 +++++++++++ .../Meshing/SetupPartTransformsTests.cs | 118 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/AcDream.Core/Meshing/SetupPartTransforms.cs create mode 100644 tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs diff --git a/src/AcDream.Core/Meshing/SetupPartTransforms.cs b/src/AcDream.Core/Meshing/SetupPartTransforms.cs new file mode 100644 index 0000000..3bffbd5 --- /dev/null +++ b/src/AcDream.Core/Meshing/SetupPartTransforms.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Meshing; + +/// +/// Compute the per-part static transforms for a Setup using its +/// PlacementFrames. For each part i, the returned matrix takes a +/// point in part-local space and yields a point in setup-local space at +/// the Setup's resting pose. +/// +/// +/// Mirrors 's pose-source priority — +/// PlacementFrames[Resting][Default] → first available +/// — so that a particle anchor at part i matches the part's +/// visible rest position. If renderer and resolver ever drift on this +/// priority, particles will visibly drift relative to their parent +/// mesh; keep them in lockstep. +/// +/// +/// +/// Returns an empty list when the Setup has no PlacementFrames. The +/// caller (e.g. ParticleHookSink.SpawnFromHook) should then fall +/// back to per part, which is the +/// pre-C.1.5b behavior. +/// +/// +/// +/// For animated entities, per-part transforms vary per animation frame +/// and live in AnimatedEntityState; a future "animated +/// DefaultScript" path would publish those each tick via the same +/// SetEntityPartTransforms seam. Out of scope here. +/// +/// +public static class SetupPartTransforms +{ + public static IReadOnlyList Compute(Setup setup) + { + AnimationFrame? source = null; + if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) + { + source = resting; + } + else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) + { + source = def; + } + else + { + foreach (var kvp in setup.PlacementFrames) + { + source = kvp.Value; + break; + } + } + + if (source is null) return System.Array.Empty(); + + int partCount = setup.Parts.Count; + var result = new Matrix4x4[partCount]; + for (int i = 0; i < partCount; i++) + { + Frame frame = i < source.Frames.Count + ? source.Frames[i] + : new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One; + result[i] = Matrix4x4.CreateScale(scale) + * Matrix4x4.CreateFromQuaternion(frame.Orientation) + * Matrix4x4.CreateTranslation(frame.Origin); + } + return result; + } +} diff --git a/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs b/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs new file mode 100644 index 0000000..dfcb6e9 --- /dev/null +++ b/tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs @@ -0,0 +1,118 @@ +using System.Numerics; +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Meshing; + +public class SetupPartTransformsTests +{ + [Fact] + public void Compute_PrefersRestingPlacement_OverDefault() + { + // Resting lifts part 1 by +Z=1; Default has zero lift on every part. + // Compute must pick Resting (matches SetupMesh.Flatten priority). + var setup = new Setup + { + Parts = { 0x01000100u, 0x01000101u }, + DefaultScale = { Vector3.One, Vector3.One }, + PlacementFrames = + { + [Placement.Resting] = new AnimationFrame(2) + { + Frames = + { + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + new Frame { Origin = new Vector3(0, 0, 1f), Orientation = Quaternion.Identity }, + }, + }, + [Placement.Default] = new AnimationFrame(2) + { + Frames = + { + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + }, + }, + }, + }; + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Equal(2, transforms.Count); + var probe = Vector3.Transform(Vector3.Zero, transforms[1]); + Assert.Equal(new Vector3(0, 0, 1f), probe); + } + + [Fact] + public void Compute_FallsBackToDefault_WhenRestingMissing() + { + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { Vector3.One }, + PlacementFrames = + { + [Placement.Default] = new AnimationFrame(1) + { + Frames = + { + new Frame { Origin = new Vector3(2f, 0, 0), Orientation = Quaternion.Identity }, + }, + }, + }, + }; + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Single(transforms); + var probe = Vector3.Transform(Vector3.Zero, transforms[0]); + Assert.Equal(new Vector3(2f, 0, 0), probe); + } + + [Fact] + public void Compute_ReturnsEmpty_WhenNoPlacementFrames() + { + // Setup with parts but no PlacementFrames — caller's + // ParticleHookSink falls back to Identity per part (pre-C.1.5b + // behavior). Returning empty signals "no per-part data available". + var setup = new Setup + { + Parts = { 0x01000100u, 0x01000101u }, + }; + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Empty(transforms); + } + + [Fact] + public void Compute_AppliesDefaultScale_WhenPresent() + { + // DefaultScale = (2,2,2) on part 0. An input (1,1,1) should + // come out (2,2,2) after the part transform — confirms the + // CreateScale factor is present in the matrix. + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { new Vector3(2f, 2f, 2f) }, + PlacementFrames = + { + [Placement.Resting] = new AnimationFrame(1) + { + Frames = + { + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + }, + }, + }, + }; + + var transforms = SetupPartTransforms.Compute(setup); + + Assert.Single(transforms); + var probe = Vector3.Transform(new Vector3(1f, 1f, 1f), transforms[0]); + Assert.Equal(new Vector3(2f, 2f, 2f), probe); + } +}