#if UNITY_EDITOR using System.Collections.Generic; using UnityEngine; namespace AutomaticDoorSystem.Editor { /// /// Heuristic pivot/orientation checks for a DoorAuthoring setup. The gizmos draw arcs from the /// panel's CURRENT world pose, but the runtime animates the panel's LocalTransform from /// quaternion.identity (rotating) or its authored localPosition (sliding) — so a setup can look /// fine in the scene view yet behave erratically in play mode. These checks compare the authored /// data against what the animation system will actually do and surface the mismatches. /// Heuristics only: every finding needs human confirmation (use the edit-mode test buttons on /// the DoorAuthoring inspector) — never auto-fix from here. /// public static class DoorPivotAnalysis { private const float RotationToleranceDegrees = 1f; /// Below this |bounds-centre offset| / half-width ratio the pivot reads as centred /// rather than sitting on a hinge edge (a proper hinge pivot has a ratio near 1). private const float CentredPivotRatio = 0.5f; public static List Analyze(DoorAuthoring door) { var warnings = new List(); if (door == null || door.doorConfig == null) return warnings; var config = door.doorConfig; var isDouble = config.doorCount == DoorConfig.DoorCountEnum.Double; var isRotating = config.doorMovement == DoorConfig.DoorMovementEnum.Rotating; if (isRotating) { CheckSwingDirection(door, warnings); } if (isDouble) { CheckPanel(door, door.leftDoorMesh, "Left panel", isRotating, warnings); CheckPanel(door, door.rightDoorMesh, "Right panel", isRotating, warnings); if (isRotating) CheckPanelsOpposition(door, warnings); if (!isRotating && config.slidingStyle == DoorConfig.SlidingStyleEnum.Mirrored && Mathf.Abs(config.slideOpenOffset.x) < 1e-4f) { warnings.Add( "Both sliding panels will slide the SAME way (Mirrored style negates X only, and Slide Open " + "Offset X is 0). Fix: put the travel on the config's Slide Open Offset X."); } } else { CheckPanel(door, door.doorMesh, "Door panel", isRotating, warnings); } return warnings; } private static void CheckPanel(DoorAuthoring door, Transform panel, string label, bool isRotating, List warnings) { if (panel == null) return; // The animation system writes the panel's LocalTransform, i.e. in its PARENT's frame, // while the config offsets/angles are authored in the door root's frame. A parent whose // rotation differs from the root silently re-aims every slide vector and rotation axis. if (panel.parent != null && Quaternion.Angle(panel.parent.rotation, door.transform.rotation) > RotationToleranceDegrees) { warnings.Add( $"{label} '{panel.name}' animates in a frame rotated {Quaternion.Angle(panel.parent.rotation, door.transform.rotation):F0}° " + $"from the door root (parent '{panel.parent.name}'), so its travel/rotation axis will not match the gizmos. " + "Fix: give the parent the same rotation as the door root."); } if (!isRotating) return; // Runtime treats quaternion.identity as the CLOSED pose. A panel authored with any other // local rotation snaps on the first animation frame and swings from the wrong start. var closedError = Quaternion.Angle(panel.localRotation, Quaternion.identity); if (closedError > RotationToleranceDegrees) { warnings.Add( $"{label} '{panel.name}' has a closed local rotation of {closedError:F0}° (the runtime expects 0°), " + "so it will SNAP on the first frame and swing from the wrong pose. " + "Fix: bake the rotation into the mesh, or re-parent the panel under a node that carries the rotation."); } // A rotating panel spins around its own pivot: the pivot must sit on the hinge edge, // with the mesh extending to one side. A centred pivot spins the panel in place. if (TryGetPanelXProfile(panel, out var centerOffsetX, out var halfExtentX) && halfExtentX > 0.01f) { var ratio = Mathf.Abs(centerOffsetX) / halfExtentX; if (ratio < CentredPivotRatio) { warnings.Add( $"{label} '{panel.name}' pivots near its centre (offset {centerOffsetX:F2} m over a {halfExtentX:F2} m half-width), " + "so it will spin in place and clip through the wall. " + "Fix: move the pivot to the hinge edge (re-export, or parent the mesh under an offset hinge node)."); } } } /// /// The world-space direction into the side the detection system treats as "front" /// (DirectionForward = 1): the root's local +Z through its full transform, negated when /// Invert Forward Side is in effect. Split at the panel pivots - see DoorSideMath. /// public static Vector3 FrontAxis(DoorAuthoring door) => door.FrontDirectionWorld; public static string AxisLabel(Vector3 axis) { axis.Normalize(); if (Vector3.Dot(axis, Vector3.forward) > 0.999f) return "world +Z"; if (Vector3.Dot(axis, Vector3.right) > 0.999f) return "world +X"; if (Vector3.Dot(axis, Vector3.back) > 0.999f) return "world -Z"; if (Vector3.Dot(axis, Vector3.left) > 0.999f) return "world -X"; return $"world ({axis.x:F2}, {axis.y:F2}, {axis.z:F2})"; } /// /// Replays the full runtime chain for a player approaching each side of the door — /// DoorSideMath picking DirectionForward (root local +Z through the panel pivots, honouring /// Invert Forward Side), the animation system's target rotation for that direction, and /// the panel's hinge-extent swing — and flags any side where the door would open TOWARD /// the player. Forward-style doors (and singles with BothWay, which the runtime treats as /// Forward) are expected to open away from whichever side you approach; OneWay and double /// BothWay open a fixed way by design and are skipped. When this fires on BOTH sides the /// fix is Invert Forward Side (config or per-door override), not the model. /// private static void CheckSwingDirection(DoorAuthoring door, List warnings) { var config = door.doorConfig; var isDouble = config.doorCount == DoorConfig.DoorCountEnum.Double; if (config.openingStyle == DoorConfig.OpeningStyle.OneWay) return; if (isDouble && config.openingStyle == DoorConfig.OpeningStyle.BothWay) return; var front = FrontAxis(door); var forward = Quaternion.Euler(0f, config.openForwardAngle, 0f); var backward = Quaternion.Euler(0f, config.openBackwardAngle, 0f); // side, DirectionForward for a player on that side, and the target rotations the // animation system would use (doubles invert forward/backward and mirror the right panel). foreach (var fromFront in new[] { true, false }) { var playerSide = fromFront ? front : -front; var sideName = fromFront ? "front" : "back"; Quaternion leftTarget, rightTarget; if (isDouble) { var baseRotation = fromFront ? backward : forward; leftTarget = baseRotation; var euler = baseRotation.eulerAngles; rightTarget = Quaternion.Euler(euler.x, -euler.y, euler.z); } else { leftTarget = rightTarget = fromFront ? forward : backward; } if (isDouble) { CheckPanelSwing(door, door.leftDoorMesh, "Left panel", leftTarget, playerSide, sideName, warnings); CheckPanelSwing(door, door.rightDoorMesh, "Right panel", rightTarget, playerSide, sideName, warnings); } else { CheckPanelSwing(door, door.doorMesh, "Door panel", leftTarget, playerSide, sideName, warnings); } } } private static void CheckPanelSwing(DoorAuthoring door, Transform panel, string label, Quaternion targetLocalRotation, Vector3 playerSide, string sideName, List warnings) { if (panel == null) return; if (!TryGetPanelXProfile(panel, out var centerOffsetX, out var halfExtentX)) return; if (halfExtentX < 0.01f || Mathf.Abs(centerOffsetX) < halfExtentX * 0.25f) return; // centred pivot: separate warning // The runtime overwrites the panel's local rotation, so the open-pose free-edge // direction is parentRotation * targetRotation * (hinge-extent in panel space). var extentLocal = new Vector3(Mathf.Sign(centerOffsetX), 0f, 0f); var parentRotation = panel.parent != null ? panel.parent.rotation : Quaternion.identity; var openEdgeWorld = parentRotation * targetLocalRotation * extentLocal; if (Vector3.Dot(openEdgeWorld, playerSide) > 0.3f) { warnings.Add( $"{label} '{panel.name}' swings TOWARD a player coming from the {sideName} ({AxisLabel(playerSide.normalized)}). " + "Fix: if both sides are reported, turn on Invert Forward Side (config or per-door override); " + "if only one side, the hinge side is off (or left/right panels are swapped on a double)."); } } /// /// Both panels of a rotating double hinge on opposite jambs and extend toward the middle of /// the doorway. If both extend the same way along the root's X the meshes are swapped /// (left/right assigned backwards) or one panel is flipped. /// private static void CheckPanelsOpposition(DoorAuthoring door, List warnings) { if (door.leftDoorMesh == null || door.rightDoorMesh == null) return; if (!TryGetWorldBoundsCenter(door.leftDoorMesh, out var leftCenter)) return; if (!TryGetWorldBoundsCenter(door.rightDoorMesh, out var rightCenter)) return; var rootRight = door.transform.right; var leftDir = Vector3.Dot(leftCenter - door.leftDoorMesh.position, rootRight); var rightDir = Vector3.Dot(rightCenter - door.rightDoorMesh.position, rootRight); if (Mathf.Abs(leftDir) > 0.01f && Mathf.Abs(rightDir) > 0.01f && leftDir * rightDir > 0f) { warnings.Add( "Left and right panels both extend the SAME way along the door's X axis instead of toward each other. " + "Fix: swap the Left/Right Door Mesh assignments, or un-flip the mirrored panel."); } } /// /// The panel's extent along its own right axis, measured from its pivot: bounds-centre offset /// and half-width. Prefers the BoxCollider (authoring source for the collider pool), falls /// back to renderer bounds. Corners are projected so rotated/parented colliders measure right. /// private static bool TryGetPanelXProfile(Transform panel, out float centerOffsetX, out float halfExtentX) { centerOffsetX = 0f; halfExtentX = 0f; var axis = panel.right; var pivot = panel.position; var box = panel.GetComponent(); if (box == null) box = panel.GetComponentInChildren(); if (box != null) { var min = float.MaxValue; var max = float.MinValue; var half = box.size * 0.5f; for (var i = 0; i < 8; i++) { var corner = box.center + new Vector3( ((i & 1) == 0 ? -1 : 1) * half.x, ((i & 2) == 0 ? -1 : 1) * half.y, ((i & 4) == 0 ? -1 : 1) * half.z); var projected = Vector3.Dot(box.transform.TransformPoint(corner) - pivot, axis); min = Mathf.Min(min, projected); max = Mathf.Max(max, projected); } centerOffsetX = (min + max) * 0.5f; halfExtentX = (max - min) * 0.5f; return true; } var renderer = panel.GetComponent(); if (renderer == null) renderer = panel.GetComponentInChildren(); if (renderer != null) { var bounds = renderer.bounds; var min = float.MaxValue; var max = float.MinValue; for (var i = 0; i < 8; i++) { var corner = bounds.center + new Vector3( ((i & 1) == 0 ? -1 : 1) * bounds.extents.x, ((i & 2) == 0 ? -1 : 1) * bounds.extents.y, ((i & 4) == 0 ? -1 : 1) * bounds.extents.z); var projected = Vector3.Dot(corner - pivot, axis); min = Mathf.Min(min, projected); max = Mathf.Max(max, projected); } centerOffsetX = (min + max) * 0.5f; halfExtentX = (max - min) * 0.5f; return true; } return false; } private static bool TryGetWorldBoundsCenter(Transform panel, out Vector3 center) { center = default; var box = panel.GetComponent(); if (box == null) box = panel.GetComponentInChildren(); if (box != null) { center = box.transform.TransformPoint(box.center); return true; } var renderer = panel.GetComponent(); if (renderer == null) renderer = panel.GetComponentInChildren(); if (renderer != null) { center = renderer.bounds.center; return true; } return false; } } } #endif