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);
+ }
+}