diff --git a/docs/research/2026-05-24-door-dat-inspection-findings.md b/docs/research/2026-05-24-door-dat-inspection-findings.md
new file mode 100644
index 0000000..3028fa5
--- /dev/null
+++ b/docs/research/2026-05-24-door-dat-inspection-findings.md
@@ -0,0 +1,258 @@
+# Door collision dat inspection — findings
+
+**Date:** 2026-05-24 (evening, continuation of door collision investigation)
+**Branch:** `claude/strange-albattani-3fc83c`
+**Status:** Evidence gathered. Hypothesis A from
+[`2026-05-24-door-collision-session-handoff.md`](2026-05-24-door-collision-session-handoff.md) **FALSIFIED**.
+
+---
+
+## TL;DR
+
+A deterministic, read-only dat-inspection test
+([`DoorSetupGfxObjInspectionTests.cs`](../../tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs))
+opens the real client dat and prints the raw state of door Setup
+`0x020019FF` + its three referenced GfxObjs.
+
+**Result — Hypothesis A is wrong.** Part 0 (`0x010044B5`) has a complete
+1.925 m × 0.261 m × 2.490 m door-sized collision volume in the dat. Six
+two-sided (`SidesType=Landblock`) physics polygons form the closed door
+slab. Bounding sphere radius 1.975 m. Setup `Flags=HasPhysicsBSP`.
+
+Parts 1, 2 (`0x010044B6`) **are** visual-only by design — `HasPhysics`
+flag is clear, `PhysicsBSP` is null, `PhysicsPolygons.Count = 0`. **This
+matches retail's `CPhysicsPart::find_obj_collisions`**
+([`acclient_2013_pseudo_c.txt:275051`](../research/named-retail/acclient_2013_pseudo_c.txt)),
+which explicitly short-circuits when `physics_bsp == 0`. So retail also
+runs no collision against `0x010044B6` — and our skip-on-null-BSP
+behavior is retail-faithful, not a bug.
+
+**This rewrites the "next-session approach" recommendation in the prior
+handoff.** The handoff said "if 0x010044B5's BSP has zero floor-touching
+polys → Hypothesis A confirmed → pivot strategy." The BSP has six
+collision polygons forming the whole door slab. The pivot is not needed;
+we need to figure out why our integration of `0x010044B5`'s BSP didn't
+fire during the Task 7 experiment.
+
+---
+
+## Raw dump (verbatim from the test)
+
+```
+=== Setup 0x020019FF ===
+ Flags = HasParent, AllowFreeHeading, HasPhysicsBSP (0x0000000D)
+ Radius = 0.1414
+ Height = 0.2000
+ StepUp = 0.0900
+ StepDown = 0.0900
+ CylSpheres = 0
+ Spheres = 1
+ [0] r=0.1000 origin=(0.000,0.000,0.018)
+ Parts = 3
+ [0] gfxObj=0x010044B5
+ [1] gfxObj=0x010044B6
+ [2] gfxObj=0x010044B6
+ PlacementFrames = 1
+ [Default] frameCount=3
+ frame[0] pos=(-0.006,0.125,1.275) rot=(0.000,0.000,0.000,1.000)
+ frame[1] pos=(0.710,0.000,1.210) rot=(0.000,0.000,0.000,1.000)
+ frame[2] pos=(0.710,0.247,1.210) rot=(0.000,0.000,1.000,0.000)
+
+=== GfxObj 0x010044B5 === (the door slab — has physics)
+ Flags = HasPhysics, HasDrawing, HasDIDDegrade (0x0000000B)
+ HasPhysics = True
+ VertexArray = non-null, 8 vertices
+ PhysicsPolygons = 6 polys
+ PhysicsBSP = non-null
+ PhysicsBSP.Root = non-null
+ Root.Type = BPnN
+ Root.BoundingSphere = (-0.390,-0.056,-0.150) r=1.975
+ BSP tree total polys (including children) = 6
+ PhysicsPolygon AABB sweep (first 6 polys):
+ [0x0000] nVerts=4 sides=Landblock min=(-0.954,-0.134,-1.236) max=(0.971,0.127,-1.236) # bottom face
+ [0x0001] nVerts=4 sides=Landblock min=(-0.954,-0.134,-1.236) max=(-0.954,0.127,1.255) # left face
+ [0x0002] nVerts=4 sides=Landblock min=(-0.954,-0.134, 1.255) max=(0.971,0.127,1.255) # top face
+ [0x0003] nVerts=4 sides=Landblock min=( 0.971,-0.134,-1.236) max=(0.971,0.127,1.255) # right face
+ [0x0004] nVerts=4 sides=Landblock min=(-0.954,-0.134,-1.236) max=(0.971,-0.134,1.255) # front face
+ [0x0005] nVerts=4 sides=Landblock min=(-0.954, 0.127,-1.236) max=(0.971,0.127,1.255) # back face
+ PhysicsPolygons combined AABB: min=(-0.954,-0.134,-1.236) max=(0.971,0.127,1.255)
+ size=(1.925, 0.261, 2.490)
+
+=== GfxObj 0x010044B6 === (the leaves — visual-only by design)
+ Flags = HasDrawing, HasDIDDegrade (0x0000000A)
+ HasPhysics = False
+ VertexArray = non-null, 40 vertices
+ PhysicsPolygons = 0 polys
+ PhysicsBSP = NULL
+ Polygons (visual) = 87 polys
+ DrawingBSP = non-null
+```
+
+---
+
+## What this means
+
+### The data is right
+
+Part 0's BSP is a six-faced thin slab oriented as a vertical door:
+1.925 m wide × 0.261 m thin × 2.490 m tall. Placed at frame[0] offset
+`(-0.006, 0.125, 1.275)`, it occupies entity-local Z ∈ `[0.039, 2.530]` —
+a standard door height. All six faces are
+`SidesType=Landblock` (two-sided collision — catches a sphere
+approaching from either side).
+
+This is exactly what retail's collision system uses to block doors.
+No mystery, no missing data, no need to fall back to a wider Cylinder
+approximation.
+
+### The leaves are correctly visual-only
+
+`0x010044B6` is the swinging door leaf (used twice — left + right
+panels). It has no physics by retail design. Our `ShadowShapeBuilder`
+skipping these parts matches both the dat and retail's
+`CPhysicsPart::find_obj_collisions`.
+
+### So the bug is in integration, not data
+
+The previous session's Task 7 experiment registered `0x010044B5`'s BSP
+correctly (we saw `type=BSP gfxObj=0x010044B5 radius=2.000
+localPos=(-0.006,0.125,1.275)` in the per-shape registration), yet got
+**zero `[resolve-bldg]` attributions** during live play. With the data
+now confirmed good, that gap must be in:
+
+1. **The BSP collision dispatch never enters for the door entry** —
+ `TransitionTypes.cs:2257` silently `continue`s when
+ `engine.DataCache.GetGfxObj(obj.GfxObjId)?.BSP?.Root is null`. If the
+ GfxObj wasn't cached at collision time (race with renderer load), the
+ entry is invisibly skipped. **No log distinguishes this from
+ "queried-and-no-hit."**
+
+2. **Broadphase placeholder radius** — Task 2's `ShadowShapeBuilder`
+ uses `bspRadius = 2f` as a stand-in pending a Task 5/6 caller
+ replacement. The real dat value is `1.975` — close enough not to be
+ the blocker, but the placeholder convention means callers MUST
+ substitute the real BS radius from `cache.GetGfxObj(gfxId).BoundingSphere.Radius`
+ before registering. If a future caller forgets, the broadphase will
+ still mostly work but won't be tight.
+
+3. **The broadphase center is the part's FRAME origin, not the BSP's
+ bounding-sphere center.** Frame origin = `(-0.006, 0.125, 1.275)`;
+ BS center in part-local = `(-0.390, -0.056, -0.150)`. Distance:
+ 1.48 m. The 2.0 m broadphase radius nominally covers the BS sphere
+ (radius 1.975) from the frame origin only on the side closest to the
+ BS center. For approaches on the opposite side, the broadphase
+ sphere extends 2.0 m + 1.48 m = 3.48 m from the BS center — wider
+ than needed, but never too tight in the door case. Still, a more
+ faithful encoding centers the broadphase on the part's BS center +
+ frame offset, with radius = BS radius.
+
+4. **BSPQuery against `SidesType=Landblock` polys** — `BSPQuery.cs`
+ pass-through-copies `SidesType` (line 2277) but doesn't filter on
+ it. We have not yet verified that `Landblock`-typed polys actually
+ produce collision hits in our query pipeline against a thin-slab
+ geometry. (Note: indoor cells use `SidesType=Single`-typed cell-floor
+ polys and those work — the cellar replay tests pin that. But Doors'
+ `Landblock` polys may behave differently — particularly w.r.t.
+ two-sided collision.)
+
+5. **Entity rotation at the doorway** — Holtburg cottage doors face
+ non-cardinal directions. The entity's world rotation
+ `entity_rot` composes with `frame[0].Rotation` (identity for part 0)
+ to produce `obj.Rotation = entity_rot`. The sphere
+ transform `inv(entity_rot) * (sphere_world − obj.Position)` is
+ sensitive to that rotation. If we register with identity (forgetting
+ to plumb the spawn's rotation through), the BSP polys will be
+ oriented "into the world" wrong — passing tests that approach from
+ the wrong axis.
+
+---
+
+## Recommended next step
+
+The handoff's "DO NOT speculate-and-fix again" rule still applies. The
+right next move is **apparatus-first**, not another implementation
+attempt:
+
+**Write a focused unit test** that:
+
+1. Loads the real `0x010044B5` PhysicsBSP from the dat via the
+ inspection test's pattern (or use `GfxObjDumpSerializer.Hydrate`
+ for a deterministic fixture).
+2. Constructs a synthetic door entity at a known world position
+ `(132.6, 17.1, 94.08)` with a known rotation (try identity AND a
+ ~90° Z rotation to cover both axes).
+3. Sweeps a player sphere at the door from each of the four
+ compass directions, at off-center positions (50 cm off-center)
+ AND dead center.
+4. Calls `Transition.FindObjCollisions` / `ResolveWithTransition`
+ directly (apparatus path mirrors the live one).
+5. Asserts:
+ - Dead-center approach → `Collided` / `Adjusted` / `Slid`
+ with `CollideObjectGuids` containing the door entity.
+ - 50 cm off-center approach → same.
+ - From inside walking out → same.
+
+If the test fails: we have a deterministic reproduction of the live
+bug in <500 ms, and we can fix the integration with confidence.
+If the test passes: the door bug is elsewhere (cell registration,
+spawn-time race, etc.).
+
+This is the next apparatus the previous session was building toward
+when it ran out of cycles. With the data question now closed by the
+dat inspection, it's the highest-information next move.
+
+---
+
+## What's in the tree right now
+
+```
+$ git status --short
+?? docs/research/2026-05-24-door-dat-inspection-findings.md
+?? tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs
+[+ untracked launch logs from prior sessions]
+```
+
+Build green; existing tests still pass; new test runs in 34 ms and
+produces the dump above.
+
+---
+
+## Pickup prompt for next session
+
+```
+Door collision dat inspection (2026-05-24 evening) FALSIFIED
+Hypothesis A. Part 0 (0x010044B5) has a full door-slab BSP in the
+dat — 6 Landblock-typed polys forming a 1.925 m × 0.261 m × 2.490 m
+collision volume. Parts 1, 2 (0x010044B6) are visual-only by retail
+design (HasPhysics flag clear). Retail and acdream both skip those
+in CPhysicsPart::find_obj_collisions — that's not a bug.
+
+ Read docs/research/2026-05-24-door-dat-inspection-findings.md
+
+ State both altitudes:
+ Currently working toward: M1.5 — Indoor world feels right
+ Current phase: A6.P4 — door collision investigation continues.
+ Per-part BSP infrastructure (Tasks 1-6) ships
+ already; data is confirmed good in the dat; need
+ to determine WHY our integration of 0x010044B5's
+ BSP didn't fire collisions during the Task 7
+ experiment.
+
+ Next moves (in order):
+ 1. Write CellarUpTrajectoryReplay-style apparatus test that
+ loads 0x010044B5's PhysicsBSP from a dat dump, registers a
+ synthetic door via RegisterMultiPart, and sweeps a player
+ sphere at it. Confirm BSP collision fires (or doesn't) in
+ isolation.
+ 2. If the test passes → bug is in live registration (likely
+ cell scoping, entity rotation, or race with renderer
+ loading). Investigate live cell membership for door
+ entities.
+ 3. If the test fails → bug is in BSPQuery.FindCollisions
+ against thin-slab Landblock-typed polys. Investigate the
+ 6-path dispatcher for that case.
+
+ DO NOT re-attempt Task 7 (per-part BSP wiring in
+ RegisterLiveEntityCollision) until the apparatus test confirms
+ the BSP works in isolation. Tasks 1-6 stay; they're correct.
+```
diff --git a/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs
new file mode 100644
index 0000000..70e3f57
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs
@@ -0,0 +1,221 @@
+using System;
+using System.IO;
+using System.Linq;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Options;
+using Xunit;
+using Xunit.Abstractions;
+using Env = System.Environment;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// A6.P4 door fix (2026-05-24) — read-only dat inspection. Opens
+/// the real client dat collection and reports the raw state of door
+/// Setup 0x020019FF + its three referenced GfxObjs (0x010044B5 and
+/// 0x010044B6 used twice). Answers the question "does the leaf GfxObj
+/// 0x010044B6 actually have a non-null PhysicsBSP in the dat, or is it
+/// genuinely visual-only?" without going through PhysicsDataCache's
+/// four early-return filters, and without launching the live client.
+///
+///
+/// SKIP if ACDREAM_DAT_DIR (or the default
+/// %USERPROFILE%\Documents\Asheron's Call directory) isn't present —
+/// keeps CI green. Local developer runs always have it.
+///
+///
+public class DoorSetupGfxObjInspectionTests
+{
+ private readonly ITestOutputHelper _out;
+ public DoorSetupGfxObjInspectionTests(ITestOutputHelper output) => _out = output;
+
+ private const uint DoorSetupId = 0x020019FFu;
+
+ ///
+ /// Read door setup 0x020019FF and every GfxObj id it references.
+ /// Emits a structured report so we can decide whether the "BSP=null
+ /// on 0x010044B6" handoff finding is a data fact or a load-order /
+ /// loader-bug artifact.
+ ///
+ [Fact]
+ public void DoorSetup_AndParts_DatInspection()
+ {
+ var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
+ ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
+ "Documents", "Asheron's Call");
+ if (!Directory.Exists(datDir))
+ {
+ _out.WriteLine($"SKIP: dat directory not found at {datDir}");
+ return;
+ }
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+
+ var setup = dats.Get(DoorSetupId);
+ Assert.NotNull(setup);
+
+ _out.WriteLine($"=== Setup 0x{DoorSetupId:X8} ===");
+ _out.WriteLine($" Flags = {setup!.Flags} (0x{(uint)setup.Flags:X8})");
+ _out.WriteLine($" Radius = {setup.Radius:F4}");
+ _out.WriteLine($" Height = {setup.Height:F4}");
+ _out.WriteLine($" StepUp = {setup.StepUpHeight:F4}");
+ _out.WriteLine($" StepDown = {setup.StepDownHeight:F4}");
+ _out.WriteLine($" CylSpheres = {setup.CylSpheres.Count}");
+ for (int i = 0; i < setup.CylSpheres.Count; i++)
+ {
+ var c = setup.CylSpheres[i];
+ _out.WriteLine($" [{i}] r={c.Radius:F4} h={c.Height:F4} origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3})");
+ }
+ _out.WriteLine($" Spheres = {setup.Spheres.Count}");
+ for (int i = 0; i < setup.Spheres.Count; i++)
+ {
+ var s = setup.Spheres[i];
+ _out.WriteLine($" [{i}] r={s.Radius:F4} origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3})");
+ }
+ _out.WriteLine($" Parts = {setup.Parts.Count}");
+ for (int i = 0; i < setup.Parts.Count; i++)
+ {
+ _out.WriteLine($" [{i}] gfxObj=0x{setup.Parts[i]:X8}");
+ }
+ _out.WriteLine($" PlacementFrames = {setup.PlacementFrames.Count}");
+ foreach (var kv in setup.PlacementFrames)
+ {
+ _out.WriteLine($" [{kv.Key}] frameCount={kv.Value.Frames.Count}");
+ for (int i = 0; i < kv.Value.Frames.Count; i++)
+ {
+ var f = kv.Value.Frames[i];
+ _out.WriteLine($" frame[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " +
+ $"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})");
+ }
+ }
+ _out.WriteLine($" HoldingLocations = {setup.HoldingLocations.Count}");
+
+ // Inspect each unique part GfxObj.
+ var uniqueGfxIds = setup.Parts.Distinct().ToList();
+ foreach (uint gfxId in uniqueGfxIds)
+ {
+ _out.WriteLine("");
+ InspectGfxObj(dats, gfxId);
+ }
+
+ _out.WriteLine("");
+ _out.WriteLine("=== Cache predicate would skip a part if: ===");
+ _out.WriteLine(" !Flags.HasFlag(HasPhysics) OR PhysicsBSP?.Root is null OR VertexArray is null");
+ _out.WriteLine("(See src/AcDream.Core/Physics/PhysicsDataCache.cs:44-47)");
+ }
+
+ private void InspectGfxObj(DatCollection dats, uint gfxId)
+ {
+ _out.WriteLine($"=== GfxObj 0x{gfxId:X8} ===");
+ var gfx = dats.Get(gfxId);
+ if (gfx is null)
+ {
+ _out.WriteLine($" Get(0x{gfxId:X8}) returned NULL — dat reader couldn't load");
+ return;
+ }
+
+ _out.WriteLine($" Flags = {gfx.Flags} (0x{(uint)gfx.Flags:X8})");
+ _out.WriteLine($" HasPhysics = {gfx.Flags.HasFlag(GfxObjFlags.HasPhysics)}");
+ _out.WriteLine($" HasDIDDegrade = {gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade)}");
+
+ _out.WriteLine($" VertexArray = {(gfx.VertexArray is null ? "NULL" : $"non-null, {gfx.VertexArray.Vertices?.Count ?? 0} vertices")}");
+ _out.WriteLine($" PhysicsPolygons = {(gfx.PhysicsPolygons is null ? "NULL" : $"{gfx.PhysicsPolygons.Count} polys")}");
+ _out.WriteLine($" PhysicsBSP = {(gfx.PhysicsBSP is null ? "NULL" : "non-null")}");
+ if (gfx.PhysicsBSP is not null)
+ {
+ _out.WriteLine($" PhysicsBSP.Root = {(gfx.PhysicsBSP.Root is null ? "NULL" : "non-null")}");
+ if (gfx.PhysicsBSP.Root is { } root)
+ {
+ _out.WriteLine($" Root.Type = {root.Type}");
+ _out.WriteLine($" Root.Polygons = {root.Polygons?.Count ?? 0}");
+ _out.WriteLine($" Root.PosNode = {(root.PosNode is null ? "null" : "non-null")}");
+ _out.WriteLine($" Root.NegNode = {(root.NegNode is null ? "null" : "non-null")}");
+ var bs = root.BoundingSphere;
+ if (bs is null)
+ _out.WriteLine(" Root.BoundingSphere = null");
+ else
+ _out.WriteLine($" Root.BoundingSphere = ({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) r={bs.Radius:F3}");
+
+ // Walk the tree to count total polygons.
+ int totalPolys = CountTotalPolys(root);
+ _out.WriteLine($" BSP tree total polys (including children) = {totalPolys}");
+ }
+ }
+
+ _out.WriteLine($" Polygons (visual) = {(gfx.Polygons is null ? "NULL" : $"{gfx.Polygons.Count} polys")}");
+ if (gfx.DrawingBSP is null)
+ {
+ _out.WriteLine($" DrawingBSP = NULL");
+ }
+ else
+ {
+ _out.WriteLine($" DrawingBSP = non-null");
+ _out.WriteLine($" DrawingBSP.Root = {(gfx.DrawingBSP.Root is null ? "NULL" : "non-null")}");
+ }
+
+ // If physics data is present, dump first few polygon AABBs in local frame
+ // — these tell us WHERE collision-bearing geometry sits relative to the
+ // door entity origin (helps distinguish "frame top above doorway" vs
+ // "leaf spanning the threshold").
+ if (gfx.PhysicsPolygons is { Count: > 0 } polys
+ && gfx.VertexArray?.Vertices is { } verts && verts.Count > 0)
+ {
+ _out.WriteLine($" PhysicsPolygon AABB sweep (first 6 polys):");
+ int dumped = 0;
+ foreach (var (pid, p) in polys)
+ {
+ if (dumped++ >= 6) break;
+ float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue;
+ float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue;
+ int nVerts = p.VertexIds.Count;
+ for (int i = 0; i < nVerts; i++)
+ {
+ if (!verts.TryGetValue((ushort)p.VertexIds[i], out var sv)) { nVerts = -1; break; }
+ var v = sv.Origin;
+ if (v.X < minX) minX = v.X;
+ if (v.Y < minY) minY = v.Y;
+ if (v.Z < minZ) minZ = v.Z;
+ if (v.X > maxX) maxX = v.X;
+ if (v.Y > maxY) maxY = v.Y;
+ if (v.Z > maxZ) maxZ = v.Z;
+ }
+ if (nVerts < 0) { _out.WriteLine($" [0x{pid:X4}] vertex lookup failed"); continue; }
+ _out.WriteLine($" [0x{pid:X4}] nVerts={nVerts} sides={p.SidesType} " +
+ $"min=({minX:F3},{minY:F3},{minZ:F3}) max=({maxX:F3},{maxY:F3},{maxZ:F3})");
+ }
+ if (polys.Count > 6)
+ _out.WriteLine($" ... ({polys.Count - 6} more)");
+
+ // Total physics polygon AABB
+ float gMinX = float.MaxValue, gMinY = float.MaxValue, gMinZ = float.MaxValue;
+ float gMaxX = float.MinValue, gMaxY = float.MinValue, gMaxZ = float.MinValue;
+ foreach (var (_, p) in polys)
+ {
+ int nVerts = p.VertexIds.Count;
+ for (int i = 0; i < nVerts; i++)
+ {
+ if (!verts.TryGetValue((ushort)p.VertexIds[i], out var sv)) continue;
+ var v = sv.Origin;
+ if (v.X < gMinX) gMinX = v.X;
+ if (v.Y < gMinY) gMinY = v.Y;
+ if (v.Z < gMinZ) gMinZ = v.Z;
+ if (v.X > gMaxX) gMaxX = v.X;
+ if (v.Y > gMaxY) gMaxY = v.Y;
+ if (v.Z > gMaxZ) gMaxZ = v.Z;
+ }
+ }
+ _out.WriteLine($" PhysicsPolygons combined AABB: min=({gMinX:F3},{gMinY:F3},{gMinZ:F3}) max=({gMaxX:F3},{gMaxY:F3},{gMaxZ:F3})");
+ _out.WriteLine($" size=({gMaxX - gMinX:F3},{gMaxY - gMinY:F3},{gMaxZ - gMinZ:F3})");
+ }
+ }
+
+ private static int CountTotalPolys(DatReaderWriter.Types.PhysicsBSPNode node)
+ {
+ int n = node.Polygons?.Count ?? 0;
+ if (node.PosNode is not null) n += CountTotalPolys(node.PosNode);
+ if (node.NegNode is not null) n += CountTotalPolys(node.NegNode);
+ return n;
+ }
+}