Guest User

Untitled

a guest
Aug 2nd, 2026
22
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
C# 104.91 KB | None | 0 0
  1. // ============================================================================
  2. //  Mesh4.cs  —  Mask ➜ Walkable FLOOR Mesh for Unity 2.5D / Adventure Creator
  3. // ============================================================================
  4. //  Merge:
  5. //    • Geometry (Floor Placement): from v1 — every mask-pixel becomes a ray
  6. //      through the camera (ViewportPointToRay) and intersects the horizontal
  7. //      plane y = floorY. This is the model that worked best when FOV changed.
  8. //      maxFloorDistance/meshDistanceScale/depthCompression/
  9. //      meshWorldOffset/targetSurfaceZ work as in v1.
  10. //    • Mesh generation: from v3 — marching-squares contour trace +
  11. //      Douglas-Peucker simplification + ear-clip triangulation. Many fewer
  12. //      polygons than the old "one quad per pixel".
  13. //    • Removed: Warp Grid (control points / RBF), Mesh Scaling
  14. //      (scaleX/Y/Z, depthMultiplier), Live Preview toggle.
  15. //    • Single mask (maskTexture) instead of array.
  16. //    • Gizmo modes as in v3: None / WhenSelected / WhenCamera / Always.
  17. //    • Player Spawn point (UV, draggable handle in Scene view) + button
  18. //      "Place Player".
  19. //    • Auto-invalidate bake: any parameter change (or camera/transform motion)
  20. //      is detected via hash; if a baked asset exists, it's auto-deleted and
  21. //      the collider reverts to live mesh.
  22. //
  23. //  Setup (NavigationMesh — walkable floor):
  24. //    1. Empty GameObject (ideally layer "NavMesh").
  25. //    2. Add Mesh4 (adds MeshCollider itself). AC Target = NavigationMesh.
  26. //    3. Set maskTexture (white = walkable) and warpCamera. Click "AC Setup".
  27. //    4. Adjust floorY / maxFloorDistance / meshWorldOffset / targetSurfaceZ
  28. //       so the floor gizmo aligns with the artwork.
  29. //    5. Bake when satisfied.
  30. //
  31. //  Setup (Hotspot — clickable area with shape from mask instead of box):
  32. //    1. Add Mesh4 ON TOP of the AC Hotspot object (or on a new object).
  33. //    2. Set AC Target = Hotspot.
  34. //    3. Set maskTexture/Polygon so the shape matches the object.
  35. //    4. Click "AC Setup" → AC.Hotspot is added, the default BoxCollider is
  36. //       removed, and the clickable shape comes from the (non-convex) MeshCollider.
  37. // ============================================================================
  38.  
  39. using System.Collections.Generic;
  40. using UnityEngine;
  41. #if UNITY_EDITOR
  42. using UnityEditor;
  43. #endif
  44.  
  45. [ExecuteAlways]
  46. [RequireComponent(typeof(MeshCollider))]
  47. [AddComponentMenu("Adventure Creator Tools/Mesh4 (Mask To Floor NavMesh)")]
  48. public class Mesh4 : MonoBehaviour
  49. {
  50.     public enum GizmoMode { None, WhenSelected, WhenCamera, Always }
  51.  
  52.     /// <summary>
  53.     /// Grid: one quad per (downsampled) pixel of the mask, as in "Script 1" — but with
  54.     /// the robust unprojection of Mesh4 (no rejection/clamp-collapse near the horizon),
  55.     /// guaranteeing 100% coverage of the mask. More polygons (controlled by downsampling).<br/>
  56.     /// Contour: contour-trace + simplify + ear-clip — many fewer polygons, but
  57.     /// Simplification slightly eats corners (Mask Dilation compensates).
  58.     /// </summary>
  59.     public enum MeshGenerationMode { Grid, Contour }
  60.  
  61.     /// <summary>
  62.     /// Perspective (default, v1-style): X/Z come from ray↔(y=floorY) intersection. Correct
  63.     /// for the NEAR portion of the mask, but depth "explodes" as v approaches the
  64.     /// camera's eye-height (v≈0.5 for non-tilted camera) — Max Floor Distance simply
  65.     /// "cuts" that explosion somewhere, without meaningful relation to hotspots.<br/>
  66.     /// Linear: X = lerp(Left X, Right X, u), Z = lerp(Near Depth Z, Far Depth Z, v),
  67.     /// Y = floorY. No camera/ray dependency — the mask is placed like a rectangle
  68.     /// in world XZ with the 4 edges EXACTLY where you define them (e.g., at the X/Z
  69.     /// of left/right/far hotspots).
  70.     /// </summary>
  71.     public enum DepthMode { Perspective, Linear }
  72.  
  73.     /// <summary>
  74.     /// Mask: the mesh is generated from PNG mask + camera/Linear unprojection
  75.     /// (see fields below).<br/>
  76.     /// Polygon: the mesh is generated directly from a list of Transform (Outline Points)
  77.     /// you place around in world space, in order. Each vertex = (point.x, floorY,
  78.     /// point.z) — triangulated with EarClip. No camera/ray/FOV involved; the mesh
  79.     /// edges match EXACTLY the points you placed (e.g., next to hotspots).
  80.     /// </summary>
  81.     public enum MeshSourceMode { Mask, Polygon }
  82.  
  83.     /// <summary>
  84.     /// Which AC component this mesh "belongs to", for the auto-setup:<br/>
  85.     /// NavigationMesh: walkable floor (AC.NavigationMesh, ignoreCollisions=off).<br/>
  86.     /// Hotspot: clickable area with shape from mask/polygon instead of box (AC.Hotspot).
  87.     /// The collider remains MeshCollider (non-convex) — as required by AC raycast
  88.     /// in 3D mode — and the default BoxCollider is removed.
  89.     /// </summary>
  90.     public enum ACTargetMode { NavigationMesh, Hotspot }
  91.  
  92.     /// <summary>Which side (world XZ) of the mesh the extension attaches to.</summary>
  93.     public enum ExtensionSide { Left, Right, Top, Bottom }
  94.  
  95.     /// <summary>
  96.     /// A rectangular "section" of walkable area added to one side of the mesh, at the SAME Y —
  97.     /// so the player can walk off-screen (e.g., at an exit point).
  98.     /// Left/Right extend on the X axis (world), Top/Bottom on the Z axis.
  99.     /// </summary>
  100.     [System.Serializable]
  101.     public class MeshExtension
  102.     {
  103.         [Tooltip("Name only for Inspector assistance.")]
  104.         public string label = "";
  105.         [Tooltip("Active/inactive without deletion.")]
  106.         public bool enabled = true;
  107.         [Tooltip("Which side of the mesh (world XZ) to attach to.")]
  108.         public ExtensionSide side = ExtensionSide.Left;
  109.         [Tooltip("How far 'outward' to extend (world units): Left/Right = X, Top/Bottom = Z.")]
  110.         public float extent = 3f;
  111.         [Tooltip("Length along the side (world units). 0 = the full side of the mesh.")]
  112.         public float span = 0f;
  113.         [Tooltip("Offset along the side (world units, +/-), for centering.")]
  114.         public float slideAlongEdge = 0f;
  115.         [Tooltip("Additional Y offset (world) for this section only (usually 0).")]
  116.         public float yOffset = 0f;
  117.         [Tooltip("Small overlap (world units) INSIDE the main mesh, to avoid a seam at the joint. 0.05-0.2 is usually enough.")]
  118.         public float overlap = 0.1f;
  119.     }
  120.  
  121.  
  122.  
  123.     // ─────────────────────────────────────────────
  124.     // MESH SOURCE
  125.     // ─────────────────────────────────────────────
  126.     [Header("AC Target")]
  127.     [Tooltip("What this mesh is used for, for the 'AC Setup' button:\n" +
  128.              "NavigationMesh: walkable floor (AC.NavigationMesh).\n" +
  129.              "Hotspot: clickable area with shape from mask/polygon (AC.Hotspot) — " +
  130.              "place the script ON TOP of the Hotspot object, click AC Setup, and the " +
  131.              "default BoxCollider is replaced by the mesh-shaped collider.")]
  132.     public ACTargetMode acTargetMode = ACTargetMode.NavigationMesh;
  133.  
  134.     [Header("Mesh Source")]
  135.     [Tooltip("Mask: generated from PNG mask + camera/Linear unprojection (fields below).\n" +
  136.              "Polygon: generated directly from Outline Points — flat mesh at Y=floorY, " +
  137.              "no camera/FOV. Edges match EXACTLY the points you place.")]
  138.     public MeshSourceMode meshSourceMode = MeshSourceMode.Mask;
  139.  
  140.     // ─────────────────────────────────────────────
  141.     // POLYGON OUTLINE  (alternative to Mask)
  142.     // ─────────────────────────────────────────────
  143.     [Header("Polygon Outline (if Mesh Source = Polygon)")]
  144.     [Tooltip("Outline points in LOCAL space (relative to this GameObject), IN ORDER. " +
  145.              "The mesh fills their interior (EarClip). Click 'Initialize Polygon from Mask' " +
  146.              "for automatic initial outline from the mask, then drag handles in Scene view " +
  147.              "to fit it in 3D space. The Y of each point is ignored (floorY is used).")]
  148.     public List<Vector3> outlinePoints = new List<Vector3>();
  149.  
  150.     [Range(6, 80)]
  151.     [Tooltip("How many points to generate from 'Initialize Polygon from Mask'. " +
  152.              "Fewer = easier editing; more = more accurate outline.")]
  153.     public int initResolution = 16;
  154.  
  155.     [Tooltip("For 'Initialize Polygon from Camera Rect': world Z of the FAR edge " +
  156.              "of the rectangle (v=1). The near edge (v=0) uses the camera's natural depth. Default 0.")]
  157.     public float rectFarZ = 0f;
  158.  
  159.     [Range(1, 12)]
  160.     [Tooltip("For 'Initialize Polygon from Camera Rect': how many segments per side " +
  161.              "(more = more handles to 'bend' the rectangle into shape).")]
  162.     public int rectSubdivisions = 1;
  163.  
  164.     [Tooltip("If true, the Polygon mesh has faces on BOTH sides (double-sided), " +
  165.              "ensuring AC raycast hits it regardless of orientation. " +
  166.              "Recommended ON for NavMesh.")]
  167.     public bool doubleSidedPolygon = true;
  168.  
  169.  
  170.  
  171.     // ─────────────────────────────────────────────
  172.     // MASK
  173.     // ─────────────────────────────────────────────
  174.     [Header("Mask (if Mesh Source = Mask)")]
  175.     [Tooltip("PNG mask — white = walkable, black = non-walkable")]
  176.     public Texture2D maskTexture;
  177.  
  178.     [Range(0.05f, 0.95f)]
  179.     [Tooltip("Brightness threshold (0-1) above which a pixel is considered walkable")]
  180.     public float threshold = 0.5f;
  181.  
  182.     [Header("Height Map (optional)")]
  183.     [Tooltip("Grayscale PNG, SAME dimensions as maskTexture (or at least same aspect). " +
  184.          "White = heightMax, Black = heightMin. Must have Read/Write Enabled in import settings.")]
  185.     public Texture2D heightTexture;
  186.  
  187.     public float heightMin = 0f;
  188.     public float heightMax = 2f;
  189.  
  190.  
  191.     [Header("Depth Map (Depth Anything etc. — replaces v-based Z)")]
  192.     public Texture2D depthMapTexture;
  193.     public float depthNearZ = 0f;   // world Z where depthValue = 1 (white/near)
  194.     public float depthFarZ = 30f;   // world Z where depthValue = 0 (black/far)
  195.  
  196.     [Range(0f, 1f)]
  197.     [Tooltip("0 = ignore depth map (pure Perspective). 1 = fully replace Z with depth map. " +
  198.          "Intermediate = slight correction on top of the already computed Perspective Z.")]
  199.     public float depthMapInfluence = 0f;
  200.  
  201.     // ─────────────────────────────────────────────
  202.     // CAMERA UNPROJECT / FLOOR  (from v1 — works well with FOV tuning)
  203.     // ─────────────────────────────────────────────
  204.     [Header("Camera Unproject / Floor")]
  205.     [Tooltip("The scene camera. Every mask-pixel is projected through this onto the floor.")]
  206.     public Camera warpCamera;
  207.  
  208.     [HideInInspector]
  209.     [Tooltip("Helper: where you see the horizon/eye-level in the artwork (0=bottom, 1=top). " +
  210.              "The 'Set Camera Tilt' button calculates the X rotation so the camera's horizon falls there. " +
  211.              "Custom-drawn next to Warp Camera.")]
  212.     public float horizonV = 0.5f;
  213.  
  214.     [Tooltip("Perspective: Z from ray↔floorY intersection (correct near, 'explodes' near " +
  215.              "camera eye-height). Linear: Z = lerp(Near/Far Depth Z, v) — no asymptote, " +
  216.              "ideal when the mask extends high (e.g., as a door to another room).")]
  217.     public DepthMode depthMode = DepthMode.Perspective;
  218.  
  219.     [Tooltip("Y position of the walkable plane in world space")]
  220.     public float floorY = -2.72f;
  221.  
  222.     [Tooltip("Only for Perspective mode. Maximum distance (world units, in depth Z) from " +
  223.              "the camera that a floor pixel is allowed to land. Beyond this, the pixel is " +
  224.              "'cut' to a constant Z = camera.z ± Max Floor Distance (Y=floorY) — not discarded, " +
  225.              "but the position there is arbitrary relative to hotspots.")]
  226.     public float maxFloorDistance = 50f;
  227.  
  228.     [Tooltip("Only for Linear mode. World X at u=0 (left edge of the mask). " +
  229.              "Set to the Transform.X of the left hotspot.")]
  230.     public float leftX = -10f;
  231.  
  232.     [Tooltip("Only for Linear mode. World X at u=1 (right edge of the mask). " +
  233.              "Set to the Transform.X of the right hotspot.")]
  234.     public float rightX = 10f;
  235.  
  236.     [Tooltip("Only for Linear mode. World Z at v=0 (bottom/near edge of the mask). " +
  237.              "Set to the same value as the Z that already works well in the near portion " +
  238.              "(see Log Depth Range in Perspective mode before switching).")]
  239.     public float nearDepthZ = 0f;
  240.  
  241.     [Tooltip("Only for Linear mode. World Z at v=1 (top/far edge of the mask). " +
  242.              "Set to the Z where you want the far portion to 'touch' — e.g., the Transform.Z " +
  243.              "of a far hotspot (Bedroom/Exit).")]
  244.     public float farDepthZ = 30f;
  245.  
  246.  
  247.     [Range(0.1f, 1f)]
  248.     [Tooltip("Ignore mask pixels above this v (cut the region near the horizon). 1 = no limit.")]
  249.     public float horizonClampV = 1f;
  250.  
  251.     [Range(0.05f, 1f)]
  252.     [Tooltip("Scales the entire walkable TOWARD the camera position. " +
  253.              "1 = exact physical floor. Smaller = closer to the camera.")]
  254.     public float meshDistanceScale = 1f;
  255.  
  256.     [Range(0.05f, 1f)]
  257.     [Tooltip("Compresses ONLY depth (Z) around the Target Surface Z, keeping X/Y fixed.")]
  258.     public float depthCompression = 1f;
  259.  
  260.     [Tooltip("Manual offset (world units) applied on every regen, AFTER unprojection.")]
  261.     public Vector3 meshWorldOffset = Vector3.zero;
  262.  
  263.     [Tooltip("Target world Z for the center of the surface — see 'Snap Surface to Z' button.")]
  264.     public float targetSurfaceZ = -5f;
  265.  
  266.     [Tooltip("If true, logs the depth range (Z) of the mesh to Console after Generate.")]
  267.     public bool logDepthRange = false;
  268.  
  269.     // ─────────────────────────────────────────────
  270.     // MESH GENERATION
  271.     // ─────────────────────────────────────────────
  272.     [Header("Mesh Generation")]
  273.     [Tooltip("Grid (default): one quad per (downsampled) pixel of the mask — like Script 1, " +
  274.              "but with Mesh4's robust unprojection (no pixel is rejected/stuck near the " +
  275.              "horizon), guaranteeing 100% coverage of the mask.\n" +
  276.              "Contour: contour-trace + simplify + ear-clip — many fewer polygons, but " +
  277.              "Simplification slightly eats corners (Mask Dilation compensates).")]
  278.     public MeshGenerationMode meshGenerationMode = MeshGenerationMode.Grid;
  279.  
  280.     [Range(1, 20)]
  281.     [Tooltip("Only for Grid mode: shrink the mask before grid (e.g., 8 = 1 quad per 8x8 px). " +
  282.              "Smaller = more accurate outline but many more polygons/vertices.")]
  283.     public int downsampling = 8;
  284.  
  285.     [Range(-30, 30)]
  286.     [Tooltip("Changes the size of the walkable area BEFORE mesh generation:\n" +
  287.              "> 0 = dilate (EXPANDS) — the collider is slightly larger than the " +
  288.              "painted area (good when the player steps out at the edges).\n" +
  289.              "< 0 = erode (SHRINKS) — the collider is slightly smaller.\n" +
  290.              "0 = no change.")]
  291.     public int maskDilation = 2;
  292.  
  293.     [Range(0f, 30f)]
  294.     [Tooltip("Contour mode: Douglas-Peucker simplification in pixels (2-4 ideal).\n" +
  295.              "Grid mode: maximum horizontal run length — merges consecutive walkable cells " +
  296.              "in the same row into one wide quad (fewer triangles, same coverage). " +
  297.              "1 = one quad/cell, larger = fewer/wider quads.\n" +
  298.              "Polygon mode: ignored (mesh is defined by Outline Points).")]
  299.     public float simplification = 2f;
  300.  
  301.     [Min(0)]
  302.     [Tooltip("Only for Contour mode: ignore islands/holes smaller than this area (px²)")]
  303.     public int minRegionArea = 100;
  304.  
  305.     public bool removeOriginalBoxCollider = true;
  306.  
  307.     // ─────────────────────────────────────────────
  308.     // MESH EXTENSIONS  (off-screen walkable)
  309.     // ─────────────────────────────────────────────
  310.     [Header("Mesh Extensions (off-screen walkable)")]
  311.     [Tooltip("Rectangular sections of walkable area added to the sides of the mesh (same Y), " +
  312.              "so the player can walk off-screen (e.g., at an exit point). " +
  313.              "Works in ALL modes. Use the '+ Left/Right/Top/Bottom' buttons.")]
  314.     public List<MeshExtension> extensions = new List<MeshExtension>();
  315.  
  316.     // ─────────────────────────────────────────────
  317.     // GLOBAL RESIZE  (entire mesh)
  318.     // ─────────────────────────────────────────────
  319.     [Header("Global Resize (entire mesh)")]
  320.     [Range(0.1f, 3f)]
  321.     [Tooltip("Uniform scale of the ENTIRE mesh around its center (world XZ). " +
  322.              "1 = nothing, 0.9 = 90% (smaller), 1.2 = larger. Y does not change. " +
  323.              "Applied AFTER extensions.")]
  324.     public float meshScaleMultiplier = 1f;
  325.  
  326.     [Tooltip("Inset/Outset: cuts (>0, SHRINKS) or adds (<0, EXPANDS) a fixed " +
  327.              "distance (world units) from ALL edges, toward the center. E.g., 0.1 = cut " +
  328.              "0.1 units everywhere. Applied AFTER scale.")]
  329.     public float meshInset = 0f;
  330.  
  331.     // ─────────────────────────────────────────────
  332.     // BAKE
  333.     // ─────────────────────────────────────────────
  334.     [Header("Bake")]
  335.     [Tooltip("If present, the MeshCollider uses this automatically. " +
  336.              "Any parameter change auto-deletes it and reverts to live mesh.")]
  337.     public Mesh bakedMeshAsset;
  338.  
  339.     [Tooltip("If ON and a baked asset exists: every change RE-BAKES in-place to the same " +
  340.              "asset (overwrite), instead of deleting the bake and reverting to live. Thus the " +
  341.              "mesh stays ALWAYS baked and you never lose the asset. (OFF = old behavior: " +
  342.              "change ➜ clear bake ➜ live.)")]
  343.     public bool autoReBake = true;
  344.  
  345.     // ─────────────────────────────────────────────
  346.     // GIZMOS
  347.     // ─────────────────────────────────────────────
  348.     [Header("Gizmos")]
  349.     public GizmoMode gizmoMode = GizmoMode.WhenSelected;
  350.     public Color fillColor = new Color(0f, 1f, 0.4f, 0.25f);
  351.     public Color wireColor = new Color(0f, 1f, 0.4f, 0.9f);
  352.  
  353.     [Tooltip("Shows two orange frames at Z = camera.z ± Max Floor Distance. " +
  354.              "Helps you visually see if the far/top portion of the mesh is 'cut' (clamped) before " +
  355.              "reaching the correct depth — e.g., compare it with the position of Bedroom/Exit hotspots.")]
  356.     public bool showDepthLimitGizmo = false;
  357.  
  358.     // ─────────────────────────────────────────────
  359.     // PLAYER SPAWN
  360.     // ─────────────────────────────────────────────
  361.     [Header("Player Spawn")]
  362.     [Tooltip("Only for Mesh Source = Mask. Entry point in mask-space (0-1). " +
  363.              "x=left→right, y=bottom→top. Default: center. Button " +
  364.              "'Spawn ➜ Mesh Center' calculates it automatically.")]
  365.     public Vector2 playerSpawnUV = new Vector2(0.5f, 0.5f);
  366.  
  367.     [Tooltip("Only for Mesh Source = Polygon. World spawn position (X,Z; Y is ignored, " +
  368.              "floorY is used). Drag the yellow indicator in Scene view.")]
  369.     public Vector3 polygonSpawnPoint = Vector3.zero;
  370.  
  371.     [Tooltip("If empty, searches for tag 'Player'")]
  372.     public Transform playerTransform;
  373.  
  374.     // ─────────────────────────────────────────────
  375.     // INTERNALS
  376.     // ─────────────────────────────────────────────
  377.     [System.NonSerialized] public int statVerts;
  378.     [System.NonSerialized] public int statTris;
  379.  
  380.     Mesh liveMesh;
  381.     MeshCollider meshCollider;
  382.     int lastHash;
  383.  
  384.     // ═════════════════════════════════════════════
  385.     // UNITY LIFECYCLE
  386.     // ═════════════════════════════════════════════
  387.  
  388.     void Reset()
  389.     {
  390.         if (warpCamera == null) warpCamera = Camera.main;
  391.  
  392. #if UNITY_EDITOR
  393.         // If the script is added onto an existing AC.Hotspot, default to Hotspot mode
  394.         // (don't add NavigationMesh or change layer to "NavMesh").
  395.         bool onHotspot = FindACType("Hotspot") != null && GetComponent(FindACType("Hotspot")) != null;
  396.         if (onHotspot) acTargetMode = ACTargetMode.Hotspot;
  397. #endif
  398.  
  399.         if (acTargetMode == ACTargetMode.NavigationMesh)
  400.         {
  401.             int navLayer = LayerMask.NameToLayer("NavMesh");
  402.             if (navLayer >= 0) gameObject.layer = navLayer;
  403.         }
  404.  
  405. #if UNITY_EDITOR
  406.         // Deferred: AddComponent inside Reset() is blocked by the editor in some Unity versions.
  407.         EditorApplication.delayCall += () =>
  408.         {
  409.             if (this == null) return;
  410.             SetupACComponents();
  411.         };
  412. #endif
  413.     }
  414.  
  415.     void OnEnable()
  416.     {
  417.         meshCollider = GetComponent<MeshCollider>();
  418.         if (removeOriginalBoxCollider) RemoveBoxCollider();
  419.         Refresh();
  420.         lastHash = ComputeHash();
  421.     }
  422.  
  423.     void OnDestroy()
  424.     {
  425.         if (liveMesh != null) DestroySafe(liveMesh);
  426.     }
  427.  
  428.     void OnValidate()
  429.     {
  430.         horizonClampV = Mathf.Clamp(horizonClampV, 0.1f, 1f);
  431.         playerSpawnUV.x = Mathf.Clamp01(playerSpawnUV.x);
  432.         playerSpawnUV.y = Mathf.Clamp01(playerSpawnUV.y);
  433.     }
  434.  
  435.     void Update()
  436.     {
  437. #if UNITY_EDITOR
  438.         if (Application.isPlaying) return;
  439.         int h = ComputeHash();
  440.         if (h != lastHash)
  441.         {
  442.             lastHash = h;
  443.             if (bakedMeshAsset != null)
  444.             {
  445.                 if (autoReBake) BakeToAsset();     // re-bake in-place, keep the asset
  446.                 else { ClearBakedAsset(true); RegenerateLive(); }
  447.             }
  448.             else
  449.             {
  450.                 RegenerateLive();
  451.             }
  452.             SceneView.RepaintAll();
  453.         }
  454. #endif
  455.     }
  456.  
  457.     int ComputeHash()
  458.     {
  459.         unchecked
  460.         {
  461.             int h = 17;
  462.             h = h * 31 + (int)meshSourceMode;
  463.             h = h * 31 + meshScaleMultiplier.GetHashCode();
  464.             h = h * 31 + meshInset.GetHashCode();
  465.             if (extensions != null)
  466.                 foreach (var ex in extensions)
  467.                 {
  468.                     if (ex == null) continue;
  469.                     h = h * 31 + (ex.enabled ? 1 : 0);
  470.                     h = h * 31 + (int)ex.side;
  471.                     h = h * 31 + ex.extent.GetHashCode();
  472.                     h = h * 31 + ex.span.GetHashCode();
  473.                     h = h * 31 + ex.slideAlongEdge.GetHashCode();
  474.                     h = h * 31 + ex.yOffset.GetHashCode();
  475.                     h = h * 31 + ex.overlap.GetHashCode();
  476.                 }
  477.             if (meshSourceMode == MeshSourceMode.Polygon)
  478.             {
  479.                 if (outlinePoints != null)
  480.                     foreach (var p in outlinePoints)
  481.                         h = h * 31 + p.GetHashCode();
  482.                 h = h * 31 + (doubleSidedPolygon ? 1 : 0);
  483.                 h = h * 31 + polygonSpawnPoint.GetHashCode();
  484.                 h = h * 31 + floorY.GetHashCode();
  485.                 h = h * 31 + transform.position.GetHashCode();
  486.                 h = h * 31 + transform.rotation.GetHashCode();
  487.                 h = h * 31 + transform.lossyScale.GetHashCode();
  488.                 return h;
  489.             }
  490.             h = h * 31 + (maskTexture != null ? maskTexture.GetEntityId().GetHashCode() : 0);
  491.             h = h * 31 + threshold.GetHashCode();
  492.             h = h * 31 + (int)meshGenerationMode;
  493.             h = h * 31 + downsampling;
  494.             h = h * 31 + (int)depthMode;
  495.             h = h * 31 + leftX.GetHashCode();
  496.             h = h * 31 + rightX.GetHashCode();
  497.             h = h * 31 + nearDepthZ.GetHashCode();
  498.             h = h * 31 + farDepthZ.GetHashCode();
  499.             h = h * 31 + floorY.GetHashCode();
  500.             h = h * 31 + maxFloorDistance.GetHashCode();
  501.             h = h * 31 + horizonClampV.GetHashCode();
  502.             h = h * 31 + meshDistanceScale.GetHashCode();
  503.             h = h * 31 + depthCompression.GetHashCode();
  504.             h = h * 31 + meshWorldOffset.GetHashCode();
  505.             h = h * 31 + targetSurfaceZ.GetHashCode();
  506.             h = h * 31 + simplification.GetHashCode();
  507.             h = h * 31 + minRegionArea;
  508.             h = h * 31 + maskDilation;
  509.  
  510.             h = h * 31 + (heightTexture != null ? heightTexture.GetEntityId().GetHashCode() : 0);
  511.             h = h * 31 + heightMin.GetHashCode();
  512.             h = h * 31 + heightMax.GetHashCode();
  513.  
  514.             if (warpCamera != null)
  515.             {
  516.                 h = h * 31 + warpCamera.transform.position.GetHashCode();
  517.                 h = h * 31 + warpCamera.transform.rotation.GetHashCode();
  518.                 h = h * 31 + warpCamera.fieldOfView.GetHashCode();
  519.                 h = h * 31 + warpCamera.aspect.GetHashCode();
  520.                 h = h * 31 + (warpCamera.orthographic ? 1 : 0);
  521.                 h = h * 31 + warpCamera.orthographicSize.GetHashCode();
  522.             }
  523.             h = h * 31 + transform.position.GetHashCode();
  524.             h = h * 31 + transform.rotation.GetHashCode();
  525.             h = h * 31 + transform.lossyScale.GetHashCode();
  526.             return h;
  527.         }
  528.     }
  529.  
  530.     // ═════════════════════════════════════════════
  531.     // FLOOR PLACEMENT  (camera unproject — from v1)
  532.     // ═════════════════════════════════════════════
  533.  
  534.     /// <summary>
  535.     /// Viewport UV (0-1, v=0 bottom) ➜ world position.<br/>
  536.     /// <b>Perspective</b>: ray through the camera, intersection with y=floorY. Correct near the
  537.     /// camera, but depth "explodes" as v approaches the camera's eye-height
  538.     /// (where the ray becomes parallel to the floor). Beyond maxFloorDistance in
  539.     /// depth Z, it does NOT follow the ray to infinity (that would create a vertical
  540.     /// "fin" near the camera). Instead, only X is projected via the X/Z ratio of the
  541.     /// ray onto constant Z = camera.z ± maxFloorDistance, with Y=floorY — a horizontal
  542.     /// "back edge", not a wall. The position there is arbitrary relative to hotspots.<br/>
  543.     /// <b>Linear</b>: X = lerp(leftX, rightX, u), Z = lerp(nearDepthZ, farDepthZ, v),
  544.     /// Y = floorY. No camera/ray dependency — the mask becomes a rectangle in world XZ
  545.     /// with the 4 edges EXACTLY where you define them (e.g., at the X/Z of left/right/far hotspots).
  546.     /// </summary>
  547.     ///
  548.  
  549.     float SampleHeight(Vector2 uv)
  550.     {
  551.         // Sample based on alpha channel
  552.         // If alpha is 0, return heightMin; if 1, return heightMax
  553.         if (heightTexture == null) return 0f;
  554.         Color c = heightTexture.GetPixelBilinear(uv.x, uv.y);
  555.         float alpha = c.a;
  556.         return Mathf.Lerp(heightMin, heightMax, alpha);
  557.     }
  558.  
  559.     public Vector3 UnprojectViewportToWorld(Vector2 uv)
  560.     {
  561.         if (warpCamera == null) return transform.position;
  562.  
  563.         Vector3 camPos = warpCamera.transform.position;
  564.         Vector3 worldHit;
  565.  
  566.         if (depthMode == DepthMode.Linear)
  567.         {
  568.             float x = Mathf.LerpUnclamped(leftX, rightX, uv.x);
  569.             float z = Mathf.LerpUnclamped(nearDepthZ, farDepthZ, uv.y);
  570.             worldHit = new Vector3(x, floorY, z);
  571.         }
  572.         else
  573.         {
  574.             Ray ray = warpCamera.ViewportPointToRay(new Vector3(uv.x, uv.y, 0f));
  575.             Plane floorPlane = new Plane(Vector3.up, new Vector3(0f, floorY, 0f));
  576.             bool gotFloorHit = floorPlane.Raycast(ray, out float dist) && dist > 0f;
  577.             if (gotFloorHit)
  578.             {
  579.                 worldHit = ray.GetPoint(dist);
  580.                 float dz = worldHit.z - camPos.z;
  581.                 if (Mathf.Abs(dz) > maxFloorDistance)
  582.                     worldHit = ProjectRayToZ(ray, camPos.z + Mathf.Sign(dz) * maxFloorDistance);
  583.             }
  584.             else
  585.             {
  586.                 float sign = !Mathf.Approximately(ray.direction.z, 0f) ? Mathf.Sign(ray.direction.z) : 1f;
  587.                 worldHit = ProjectRayToZ(ray, camPos.z + sign * maxFloorDistance);
  588.             }
  589.  
  590.             // ── Depth map correction (optional) ──
  591.             if (depthMapTexture != null && depthMapInfluence > 0.001f)
  592.             {
  593.                 float d = depthMapTexture.GetPixelBilinear(uv.x, uv.y).grayscale;
  594.                 float depthZ = Mathf.Lerp(depthFarZ, depthNearZ, d); // white(1)=near
  595.                 float correctedZ = Mathf.Lerp(worldHit.z, depthZ, depthMapInfluence);
  596.                 // Recalculate X to stay on the same ray at the new Z
  597.                 worldHit = ProjectRayToZ(ray, correctedZ);
  598.             }
  599.         }
  600.  
  601.         // Scale toward the camera position along the ray.
  602.         if (meshDistanceScale < 0.999f)
  603.             worldHit = camPos + meshDistanceScale * (worldHit - camPos);
  604.  
  605.         // Depth compression around targetSurfaceZ.
  606.         if (depthCompression < 0.999f)
  607.             worldHit.z = targetSurfaceZ + depthCompression * (worldHit.z - targetSurfaceZ);
  608.  
  609.         worldHit.y += SampleHeight(uv);
  610.         worldHit += meshWorldOffset;
  611.         return worldHit;
  612.     }
  613.  
  614.     /// <summary>
  615.     /// Projects the ray onto a constant world Z depth: calculates X via the X/Z ratio of
  616.     /// the direction (same triangle as ray↔floorY intersection, but with Z as the constraint
  617.     /// instead of Y), and sets Y=floorY. Used both for the depth-cap in Perspective mode
  618.     /// and for EVERY point in Linear mode.
  619.     /// </summary>
  620.     Vector3 ProjectRayToZ(Ray ray, float targetZ)
  621.     {
  622.         float x = ray.origin.x;
  623.         if (Mathf.Abs(ray.direction.z) > 1e-6f)
  624.             x = ray.origin.x + ray.direction.x / ray.direction.z * (targetZ - ray.origin.z);
  625.         return new Vector3(x, floorY, targetZ);
  626.     }
  627.  
  628.  
  629.     public Vector3 UnprojectViewportToLocal(Vector2 uv) =>
  630.         transform.InverseTransformPoint(UnprojectViewportToWorld(uv));
  631.  
  632.     /// <summary>world ➜ viewport UV (camera projection, for the spawn handle).</summary>
  633.     public Vector2 WorldToUV(Vector3 world)
  634.     {
  635.         if (warpCamera == null) return new Vector2(0.5f, 0.25f);
  636.         Vector3 vp = warpCamera.WorldToViewportPoint(world);
  637.         return new Vector2(vp.x, vp.y);
  638.     }
  639.  
  640.     // ═════════════════════════════════════════════
  641.     // SNAP SURFACE TO Z  (from v1)
  642.     // ═════════════════════════════════════════════
  643.  
  644.     public void SnapSurfaceToZ()
  645.     {
  646.         if (warpCamera == null)
  647.         {
  648.             Debug.LogWarning("[Mesh4] Snap Surface to Z requires warp camera.");
  649.             return;
  650.         }
  651.  
  652.         Mesh m = GetActiveMesh();
  653.         if (m == null || m.vertexCount == 0)
  654.         {
  655.             RegenerateLive();
  656.             m = liveMesh;
  657.         }
  658.         if (m == null || m.vertexCount == 0)
  659.         {
  660.             Debug.LogWarning("[Mesh4] No mesh to snap — generate first.");
  661.             return;
  662.         }
  663.  
  664.         var verts = m.vertices;
  665.         double sumZ = 0;
  666.         for (int i = 0; i < verts.Length; i++)
  667.             sumZ += transform.TransformPoint(verts[i]).z;
  668.         float centroidZ = (float)(sumZ / verts.Length);
  669.  
  670.         float delta = targetSurfaceZ - centroidZ;
  671.         meshWorldOffset.z += delta;
  672.  
  673.         Debug.Log($"[Mesh4] Snap surface: centroid Z {centroidZ:F2} → {targetSurfaceZ:F2} (Δz {delta:+0.00;-0.00}).");
  674.  
  675. #if UNITY_EDITOR
  676.         EditorUtility.SetDirty(this);
  677.         if (bakedMeshAsset != null)
  678.         {
  679.             if (autoReBake) { BakeToAsset(); return; }
  680.             ClearBakedAsset(true);
  681.         }
  682. #endif
  683.         RegenerateLive();
  684.     }
  685.  
  686.     // ═════════════════════════════════════════════
  687.     // SPAWN POINT
  688.     // ═════════════════════════════════════════════
  689.  
  690.     public Vector3 GetSpawnWorldPosition()
  691.     {
  692.         if (meshSourceMode == MeshSourceMode.Polygon)
  693.             return new Vector3(polygonSpawnPoint.x, floorY, polygonSpawnPoint.z);
  694.         return UnprojectViewportToWorld(playerSpawnUV);
  695.     }
  696.  
  697.     /// <summary>
  698.     /// Mask mode: calculates the (area-weighted) centroid of the largest walkable
  699.     /// region of the mask and places the playerSpawnUV there.<br/>
  700.     /// Polygon mode: calculates the centroid of the Outline Points and places
  701.     /// the polygonSpawnPoint there.
  702.     /// </summary>
  703.     public void CenterSpawnOnMesh()
  704.     {
  705.         if (meshSourceMode == MeshSourceMode.Polygon)
  706.         {
  707.             if (outlinePoints == null || outlinePoints.Count < 3)
  708.             {
  709.                 Debug.LogWarning("[Mesh4] Polygon mode: need at least 3 Outline Points.");
  710.                 return;
  711.             }
  712.             var poly = new List<Vector2>(outlinePoints.Count);
  713.             foreach (var p in outlinePoints)
  714.             {
  715.                 Vector3 wp = transform.TransformPoint(p);
  716.                 poly.Add(new Vector2(wp.x, wp.z));
  717.             }
  718.             if (poly.Count < 3) return;
  719.  
  720.             Vector2 c = PolygonCentroid(poly);
  721. #if UNITY_EDITOR
  722.             Undo.RecordObject(this, "Center Spawn On Mesh");
  723. #endif
  724.             polygonSpawnPoint = new Vector3(c.x, floorY, c.y);
  725. #if UNITY_EDITOR
  726.             EditorUtility.SetDirty(this);
  727. #endif
  728.             Debug.Log($"[Mesh4] Spawn ➜ polygon center, world ({c.x:0.##}, {floorY:0.##}, {c.y:0.##}).");
  729.             return;
  730.         }
  731.  
  732.         if (!ComputeWalkablePolygons(out var outers, out _, out int w, out int h))
  733.         {
  734.             Debug.LogWarning("[Mesh4] No walkable region found — cannot calculate center.");
  735.             return;
  736.         }
  737.  
  738.         List<Vector2> best = null;
  739.         float bestArea = -1f;
  740.         foreach (var o in outers)
  741.         {
  742.             float a = Mathf.Abs(SignedArea(o));
  743.             if (a > bestArea) { bestArea = a; best = o; }
  744.         }
  745.         if (best == null) return;
  746.  
  747.         Vector2 c2 = PolygonCentroid(best);
  748.         Vector2 uv = new Vector2(Mathf.Clamp01(c2.x / w), Mathf.Clamp01(c2.y / h));
  749.  
  750. #if UNITY_EDITOR
  751.         Undo.RecordObject(this, "Center Spawn On Mesh");
  752. #endif
  753.         playerSpawnUV = uv;
  754. #if UNITY_EDITOR
  755.         EditorUtility.SetDirty(this);
  756. #endif
  757.         Debug.Log($"[Mesh4] Spawn ➜ mesh center, UV ({uv.x:0.##}, {uv.y:0.##}).");
  758.     }
  759.  
  760.     static Vector2 PolygonCentroid(List<Vector2> poly)
  761.     {
  762.         float sixA = 0f, cx = 0f, cy = 0f;
  763.         for (int i = 0; i < poly.Count; i++)
  764.         {
  765.             Vector2 p0 = poly[i], p1 = poly[(i + 1) % poly.Count];
  766.             float cross = p0.x * p1.y - p1.x * p0.y;
  767.             sixA += cross;
  768.             cx += (p0.x + p1.x) * cross;
  769.             cy += (p0.y + p1.y) * cross;
  770.         }
  771.         sixA *= 3f; // 6 * signed area
  772.         if (Mathf.Abs(sixA) < 1e-9f)
  773.         {
  774.             Vector2 avg = Vector2.zero;
  775.             foreach (var p in poly) avg += p;
  776.             return avg / poly.Count;
  777.         }
  778.         return new Vector2(cx / sixA, cy / sixA);
  779.     }
  780.  
  781.     public void PlacePlayerAtSpawn()
  782.     {
  783.         Transform t = playerTransform;
  784.         if (t == null)
  785.         {
  786.             GameObject p = GameObject.FindWithTag("Player");
  787.             if (p != null) t = p.transform;
  788.         }
  789.         if (t == null)
  790.         {
  791.             Debug.LogWarning("[Mesh4] Player not found (set playerTransform or tag 'Player').");
  792.             return;
  793.         }
  794. #if UNITY_EDITOR
  795.         Undo.RecordObject(t, "Place Player At Spawn");
  796. #endif
  797.         t.position = GetSpawnWorldPosition();
  798. #if UNITY_EDITOR
  799.         EditorUtility.SetDirty(t);
  800. #endif
  801.         Debug.Log("[Mesh4] Player ➜ " + t.position);
  802.     }
  803.  
  804.     // ═════════════════════════════════════════════
  805.     // HORIZON ➜ CAMERA TILT  (helper for new backgrounds)
  806.     // ═════════════════════════════════════════════
  807.  
  808.     /// <summary>
  809.     /// Suggested X rotation (degrees, looking down = positive) so the camera's horizon
  810.     /// falls at the artwork's horizonV.
  811.     /// For a camera with pitch p (down positive), the horizon (where the ray is horizontal)
  812.     /// appears at viewport v = 0.5 + p/FOV_v (linear around center). Solving for p:
  813.     /// p = (horizonV - 0.5) * FOV_v.
  814.     /// If horizonV > 0.5 (horizon high → you see a lot of floor), p > 0 → looking down.
  815.     /// </summary>
  816.     public float SuggestedTiltX()
  817.     {
  818.         float fovV = (warpCamera != null && !warpCamera.orthographic) ? warpCamera.fieldOfView : 45f;
  819.         return (horizonV - 0.5f) * fovV;
  820.     }
  821.  
  822.     public void ApplyHorizonTilt()
  823.     {
  824.         if (warpCamera == null)
  825.         {
  826.             Debug.LogWarning("[Mesh4] Set Camera Tilt: set warpCamera.");
  827.             return;
  828.         }
  829.         float tilt = SuggestedTiltX();
  830. #if UNITY_EDITOR
  831.         Undo.RecordObject(warpCamera.transform, "Set Camera Tilt From Horizon");
  832. #endif
  833.         Vector3 e = warpCamera.transform.eulerAngles;
  834.         e.x = tilt;
  835.         warpCamera.transform.eulerAngles = e;
  836. #if UNITY_EDITOR
  837.         EditorUtility.SetDirty(warpCamera.transform);
  838. #endif
  839.         Debug.Log($"[Mesh4] Camera rotation X ➜ {tilt:0.##}° (horizon v={horizonV:0.##}, FOV={warpCamera.fieldOfView:0.#}). " +
  840.                   "Adjust floorY/FOV if needed.");
  841.         RegenerateLive();
  842.     }
  843.  
  844.     // ═════════════════════════════════════════════
  845.     // REFRESH / MESH ASSIGNMENT
  846.     // ═════════════════════════════════════════════
  847.  
  848.     public void Refresh()
  849.     {
  850.         if (meshCollider == null) meshCollider = GetComponent<MeshCollider>();
  851.         if (bakedMeshAsset != null) { AssignMesh(bakedMeshAsset); return; }
  852.         RegenerateLive();
  853.     }
  854.  
  855.     public void RegenerateLive()
  856.     {
  857.         Mesh m = GenerateCollisionMesh();
  858.         if (m == null) return;
  859.         if (liveMesh != null) DestroySafe(liveMesh);
  860.         liveMesh = m;
  861.         liveMesh.hideFlags = HideFlags.DontSave;
  862.         AssignMesh(liveMesh);
  863.     }
  864.  
  865.     void AssignMesh(Mesh m)
  866.     {
  867.         if (meshCollider == null) meshCollider = GetComponent<MeshCollider>();
  868.         if (meshCollider != null)
  869.         {
  870.             meshCollider.sharedMesh = null;
  871.             meshCollider.convex = false;
  872.             meshCollider.sharedMesh = m;
  873.         }
  874.         statVerts = (m != null) ? m.vertexCount : 0;
  875.         statTris = (m != null) ? m.triangles.Length / 3 : 0;
  876.     }
  877.  
  878.     public Mesh GetActiveMesh()
  879.     {
  880.         if (bakedMeshAsset != null) return bakedMeshAsset;
  881.         if (liveMesh != null) return liveMesh;
  882.         if (meshCollider == null) meshCollider = GetComponent<MeshCollider>();
  883.         return meshCollider != null ? meshCollider.sharedMesh : null;
  884.     }
  885.  
  886.     void RemoveBoxCollider()
  887.     {
  888.         var bc = GetComponent<BoxCollider>();
  889.         if (bc == null) return;
  890. #if UNITY_EDITOR
  891.         DestroyImmediate(bc);
  892. #else
  893.         Destroy(bc);
  894. #endif
  895.         var mr = GetComponent<MeshRenderer>();
  896.         if (mr != null) mr.enabled = false;
  897.     }
  898.  
  899.     static void DestroySafe(Object o)
  900.     {
  901.         if (o == null) return;
  902.         if (Application.isPlaying) Destroy(o); else DestroyImmediate(o);
  903.     }
  904.  
  905.     // ═════════════════════════════════════════════
  906.     // MESH GENERATION  (contour trace + simplify + ear-clip — from v3,
  907.     // but vertices come from UnprojectViewportToWorld — v1 geometry)
  908.     // ═════════════════════════════════════════════
  909.  
  910.     /// <summary>
  911.     /// Mask ➜ threshold ➜ dilation ➜ horizon clamp ➜ contour trace ➜ simplify ➜
  912.     /// sort into outer/hole loops (mask-pixel space). Used by both GenerateCollisionMesh
  913.     /// and CenterSpawnOnMesh.
  914.     /// </summary>
  915.     bool ComputeWalkablePolygons(out List<List<Vector2>> outers, out List<List<Vector2>> holes, out int w, out int h)
  916.     {
  917.         outers = null; holes = null; w = 0; h = 0;
  918.         if (maskTexture == null) return false;
  919.  
  920.         Color32[] pixels = ReadPixels(maskTexture, out w, out h);
  921.         if (pixels == null || pixels.Length != w * h) return false;
  922.  
  923.         bool[] solid = new bool[w * h];
  924.         int thr = Mathf.RoundToInt(threshold * 255f);
  925.         for (int i = 0; i < solid.Length; i++)
  926.         {
  927.             Color32 c = pixels[i];
  928.             solid[i] = (c.r + c.g + c.b) / 3 > thr;
  929.         }
  930.  
  931.         // Resize walkable area BEFORE simplification:
  932.         //  > 0 dilate (expands, prevents fall-through at edges),
  933.         //  < 0 erode  (shrinks).
  934.         if (maskDilation > 0)
  935.             solid = DilateMask(solid, w, h, maskDilation);
  936.         else if (maskDilation < 0)
  937.             solid = ErodeMask(solid, w, h, -maskDilation);
  938.  
  939.         // Horizon clamp applied AFTER dilation, so it stays a hard cutoff.
  940.         int yMax = Mathf.RoundToInt(horizonClampV * h);
  941.         for (int i = 0; i < solid.Length; i++)
  942.             if (i / w >= yMax) solid[i] = false;
  943.  
  944.         List<List<Vector2>> loops = TraceContours(solid, w, h);
  945.         if (loops.Count == 0) return false;
  946.  
  947.         outers = new List<List<Vector2>>();
  948.         holes = new List<List<Vector2>>();
  949.         foreach (var loop in loops)
  950.         {
  951.             if (Mathf.Abs(SignedArea(loop)) < minRegionArea) continue;
  952.             var simp = SimplifyClosed(loop, simplification);
  953.             if (simp.Count < 3) continue;
  954.             if (SignedArea(simp) > 0f) outers.Add(simp);
  955.             else holes.Add(simp);
  956.         }
  957.         return outers.Count > 0;
  958.     }
  959.  
  960.     public Mesh GenerateCollisionMesh()
  961.     {
  962.         if (meshSourceMode == MeshSourceMode.Polygon)
  963.             return GeneratePolygonMesh();
  964.  
  965.         if (maskTexture == null) return null;
  966.         if (warpCamera == null)
  967.         {
  968.             Debug.LogWarning("[Mesh4] Missing warp camera — required for camera-unproject.");
  969.             return null;
  970.         }
  971.  
  972.         return meshGenerationMode == MeshGenerationMode.Grid ? GenerateGridMesh() : GenerateContourMesh();
  973.     }
  974.  
  975.     /// <summary>
  976.     /// Polygon mode: takes Outline Points (LOCAL space), uses (x,z) as the 2D
  977.     /// outline with Y=floorY (in local), and triangulates with the same EarClip
  978.     /// from Contour mode. No camera/ray/FOV. If doubleSidedPolygon=true, faces are
  979.     /// added on both sides so AC raycast hits it reliably.
  980.     /// </summary>
  981.     Mesh GeneratePolygonMesh()
  982.     {
  983.         if (outlinePoints == null || outlinePoints.Count < 3)
  984.         {
  985.             Debug.LogWarning("[Mesh4] Polygon mode: need at least 3 Outline Points. " +
  986.                              "Click 'Initialize Polygon from Mask' or add points.");
  987.             return null;
  988.         }
  989.  
  990.         var poly2D = new List<Vector2>(outlinePoints.Count);
  991.         foreach (var p in outlinePoints) poly2D.Add(new Vector2(p.x, p.z));
  992.  
  993.         if (Mathf.Abs(SignedArea(poly2D)) < 1e-6f)
  994.         {
  995.             Debug.LogWarning("[Mesh4] Polygon mode: Outline Points have zero area (collinear?).");
  996.             return null;
  997.         }
  998.  
  999.         List<int> tris = EarClip(poly2D);
  1000.         if (tris.Count < 3) return null;
  1001.  
  1002.         // Local-space vertices: floorY is world; convert to local Y via inverse.
  1003.         // Keep X/Z from points (already local) and set constant local-Y corresponding
  1004.         // to world floorY under this transform.
  1005.         float localFloorY = transform.InverseTransformPoint(new Vector3(0f, floorY, 0f)).y;
  1006.         var verts = new List<Vector3>(poly2D.Count);
  1007.         foreach (var p in poly2D) verts.Add(new Vector3(p.x, localFloorY, p.y));
  1008.  
  1009.         var indices = new List<int>(tris);
  1010.  
  1011.         // Top face points +Y. Ensure correct winding so RecalculateNormals
  1012.         // produces normal toward +Y (so AC raycast from above hits it).
  1013.         if (!FaceIsUpward(verts, indices))
  1014.             for (int i = 0; i + 2 < indices.Count; i += 3)
  1015.             {
  1016.                 int tmp = indices[i + 1]; indices[i + 1] = indices[i + 2]; indices[i + 2] = tmp;
  1017.             }
  1018.  
  1019.         if (doubleSidedPolygon)
  1020.         {
  1021.             // Add same vertices again with reversed winding → bottom face.
  1022.             int baseIdx = verts.Count;
  1023.             verts.AddRange(verts.GetRange(0, baseIdx));
  1024.             int topCount = indices.Count;
  1025.             for (int i = 0; i + 2 < topCount; i += 3)
  1026.             {
  1027.                 indices.Add(baseIdx + indices[i]);
  1028.                 indices.Add(baseIdx + indices[i + 2]);
  1029.                 indices.Add(baseIdx + indices[i + 1]);
  1030.             }
  1031.         }
  1032.  
  1033.         AppendExtensionsLocal(verts, indices);
  1034.         ApplyGlobalResize(verts);
  1035.  
  1036.         Mesh mesh = new Mesh { name = gameObject.name + "_NavMesh" };
  1037.         if (verts.Count > 65000)
  1038.             mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
  1039.         mesh.SetVertices(verts);
  1040.         mesh.SetTriangles(indices, 0);
  1041.         mesh.RecalculateNormals();
  1042.         mesh.RecalculateBounds();
  1043.  
  1044.         statVerts = verts.Count;
  1045.         statTris = indices.Count / 3;
  1046.         return mesh;
  1047.     }
  1048.  
  1049.     /// <summary>True if the first non-degenerate triangle has normal toward +Y.</summary>
  1050.     static bool FaceIsUpward(List<Vector3> verts, List<int> indices)
  1051.     {
  1052.         for (int i = 0; i + 2 < indices.Count; i += 3)
  1053.         {
  1054.             Vector3 nrm = Vector3.Cross(verts[indices[i + 1]] - verts[indices[i]],
  1055.                                         verts[indices[i + 2]] - verts[indices[i]]);
  1056.             if (nrm.sqrMagnitude > 1e-10f) return nrm.y > 0f;
  1057.         }
  1058.         return true;
  1059.     }
  1060.  
  1061.     /// <summary>
  1062.     /// Fills outlinePoints with an initial outline (local space) from the mask:
  1063.     /// trace the largest outer contour, simplify to ~initResolution points, and
  1064.     /// unproject each point through the current Mask placement (camera or Linear).
  1065.     /// This gives you an outline that already "sits" where the mask mesh sits,
  1066.     /// then you drag handles to perfect it.
  1067.     /// </summary>
  1068.     public void InitializePolygonFromMask()
  1069.     {
  1070.         if (maskTexture == null)
  1071.         {
  1072.             Debug.LogWarning("[Mesh4] Initialize from Mask: set Mask Texture first.");
  1073.             return;
  1074.         }
  1075.         if (meshSourceMode == MeshSourceMode.Mask && depthMode == DepthMode.Perspective && warpCamera == null)
  1076.         {
  1077.             Debug.LogWarning("[Mesh4] Initialize from Mask: Perspective placement requires warpCamera " +
  1078.                              "(or set Depth Mode = Linear).");
  1079.             return;
  1080.         }
  1081.  
  1082.         if (!ComputeWalkablePolygons(out var outers, out _, out int w, out int h))
  1083.         {
  1084.             Debug.LogWarning("[Mesh4] Initialize from Mask: no walkable region found in mask.");
  1085.             return;
  1086.         }
  1087.  
  1088.         // Largest outer.
  1089.         List<Vector2> best = null; float bestArea = -1f;
  1090.         foreach (var o in outers)
  1091.         {
  1092.             float a = Mathf.Abs(SignedArea(o));
  1093.             if (a > bestArea) { bestArea = a; best = o; }
  1094.         }
  1095.         if (best == null || best.Count < 3) return;
  1096.  
  1097.         // Simplify to ~initResolution points: increase epsilon until reaching target
  1098.         // (binary-ish search, few iterations suffice).
  1099.         List<Vector2> reduced = ReducePointCount(best, Mathf.Max(6, initResolution));
  1100.  
  1101. #if UNITY_EDITOR
  1102.         Undo.RecordObject(this, "Initialize Polygon From Mask");
  1103. #endif
  1104.         outlinePoints = new List<Vector3>(reduced.Count);
  1105.         foreach (var pt in reduced)
  1106.         {
  1107.             Vector2 uv = new Vector2(pt.x / w, pt.y / h);
  1108.             Vector3 world = UnprojectViewportToWorld(uv);
  1109.             outlinePoints.Add(transform.InverseTransformPoint(world));
  1110.         }
  1111.  
  1112.         // Spawn = centroid.
  1113.         Vector2 c = PolygonCentroid(reduced);
  1114.         Vector2 cuv = new Vector2(c.x / w, c.y / h);
  1115.         Vector3 cworld = UnprojectViewportToWorld(cuv);
  1116.         polygonSpawnPoint = new Vector3(cworld.x, floorY, cworld.z);
  1117.  
  1118.         meshSourceMode = MeshSourceMode.Polygon;
  1119. #if UNITY_EDITOR
  1120.         EditorUtility.SetDirty(this);
  1121. #endif
  1122.         Debug.Log($"[Mesh4] Initialize from Mask: {outlinePoints.Count} points. " +
  1123.                   "Drag handles in Scene view to fit in 3D space.");
  1124.         RegenerateLive();
  1125.     }
  1126.  
  1127.     /// <summary>
  1128.     /// Creates a rectangle outline at floorY WITHOUT a mask: the 4 corners are
  1129.     /// projected from the camera's viewport corners (bottom-left → bottom-right
  1130.     /// → top-right → top-left). The near edge (v=0) falls at the camera's natural
  1131.     /// depth, the far edge (v=1) at rectFarZ. With rectSubdivisions>1, intermediate
  1132.     /// points are added per side so you can 'bend' the rectangle into shape.
  1133.     /// Useful when the mask is out of bounds / unusable — start from a clean
  1134.     /// rectangle and drag it where you want.
  1135.     /// </summary>
  1136.     public void InitializePolygonFromCameraRect()
  1137.     {
  1138.         if (warpCamera == null)
  1139.         {
  1140.             Debug.LogWarning("[Mesh4] Initialize from Camera Rect: set warpCamera.");
  1141.             return;
  1142.         }
  1143.  
  1144.         // Viewport corners: v=0 (near) at natural depth, v=1 (far) at rectFarZ.
  1145.         // Get X from camera-unproject (Perspective floor hit) and manually set Z
  1146.         // so the rectangle is "clean" and controlled.
  1147.         Vector3 nearL = RectCorner(0f, 0f, false);
  1148.         Vector3 nearR = RectCorner(1f, 0f, false);
  1149.         Vector3 farR = RectCorner(1f, 1f, true);
  1150.         Vector3 farL = RectCorner(0f, 1f, true);
  1151.  
  1152.         int seg = Mathf.Max(1, rectSubdivisions);
  1153.         var ring = new List<Vector3>();
  1154.         AppendEdge(ring, nearL, nearR, seg); // bottom
  1155.         AppendEdge(ring, nearR, farR, seg);  // right
  1156.         AppendEdge(ring, farR, farL, seg);   // top
  1157.         AppendEdge(ring, farL, nearL, seg);  // left
  1158.  
  1159. #if UNITY_EDITOR
  1160.         Undo.RecordObject(this, "Initialize Polygon From Camera Rect");
  1161. #endif
  1162.         outlinePoints = new List<Vector3>(ring.Count);
  1163.         foreach (var w in ring)
  1164.             outlinePoints.Add(transform.InverseTransformPoint(w));
  1165.  
  1166.         Vector3 center = (nearL + nearR + farR + farL) * 0.25f;
  1167.         polygonSpawnPoint = new Vector3(center.x, floorY, center.z);
  1168.  
  1169.         meshSourceMode = MeshSourceMode.Polygon;
  1170. #if UNITY_EDITOR
  1171.         EditorUtility.SetDirty(this);
  1172. #endif
  1173.         Debug.Log($"[Mesh4] Initialize from Camera Rect: {outlinePoints.Count} points " +
  1174.                   $"(near→far Z, far={rectFarZ:0.##}). Drag handles for final shape.");
  1175.         RegenerateLive();
  1176.     }
  1177.  
  1178.     /// <summary>Rectangle corner at floorY: X via camera-unproject, Z manual if far.</summary>
  1179.     Vector3 RectCorner(float u, float v, bool far)
  1180.     {
  1181.         Ray ray = warpCamera.ViewportPointToRay(new Vector3(u, v, 0f));
  1182.         Vector3 camPos = warpCamera.transform.position;
  1183.  
  1184.         float targetZ;
  1185.         if (far)
  1186.         {
  1187.             targetZ = rectFarZ;
  1188.         }
  1189.         else
  1190.         {
  1191.             // Near edge: natural intersection with floorY (if exists), otherwise slightly in front
  1192.             // of the camera.
  1193.             Plane floor = new Plane(Vector3.up, new Vector3(0f, floorY, 0f));
  1194.             if (floor.Raycast(ray, out float d) && d > 0f)
  1195.                 targetZ = ray.GetPoint(d).z;
  1196.             else
  1197.                 targetZ = camPos.z + Mathf.Sign(rectFarZ - camPos.z) * 1f;
  1198.         }
  1199.  
  1200.         float x = ray.origin.x;
  1201.         if (Mathf.Abs(ray.direction.z) > 1e-6f)
  1202.             x = ray.origin.x + ray.direction.x / ray.direction.z * (targetZ - ray.origin.z);
  1203.         return new Vector3(x, floorY, targetZ);
  1204.     }
  1205.  
  1206.     static void AppendEdge(List<Vector3> ring, Vector3 a, Vector3 b, int segments)
  1207.     {
  1208.         // Adds a and intermediates, WITHOUT b (b is added by the next edge).
  1209.         for (int i = 0; i < segments; i++)
  1210.             ring.Add(Vector3.Lerp(a, b, (float)i / segments));
  1211.     }
  1212.  
  1213.     /// <summary>Reduces a closed polygon to ~target points by increasing RDP epsilon.</summary>
  1214.     static List<Vector2> ReducePointCount(List<Vector2> poly, int target)
  1215.     {
  1216.         if (poly.Count <= target) return new List<Vector2>(poly);
  1217.  
  1218.         float lo = 0.5f, hi = 0f;
  1219.         // bbox diagonal as upper epsilon bound.
  1220.         float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
  1221.         foreach (var p in poly) { minX = Mathf.Min(minX, p.x); minY = Mathf.Min(minY, p.y); maxX = Mathf.Max(maxX, p.x); maxY = Mathf.Max(maxY, p.y); }
  1222.         hi = new Vector2(maxX - minX, maxY - minY).magnitude;
  1223.  
  1224.         List<Vector2> bestFit = SimplifyClosed(poly, lo);
  1225.         for (int iter = 0; iter < 24; iter++)
  1226.         {
  1227.             float mid = (lo + hi) * 0.5f;
  1228.             var s = SimplifyClosed(poly, mid);
  1229.             if (s.Count > target) lo = mid;
  1230.             else { hi = mid; bestFit = s; }
  1231.             if (Mathf.Abs(s.Count - target) <= 1) { bestFit = s; break; }
  1232.         }
  1233.         return bestFit.Count >= 3 ? bestFit : new List<Vector2>(poly);
  1234.     }
  1235.  
  1236.     /// <summary>Adds a new point between the last and first (closed loop).</summary>
  1237.     public void AddOutlinePointAfterLast()
  1238.     {
  1239.         if (outlinePoints == null) outlinePoints = new List<Vector3>();
  1240. #if UNITY_EDITOR
  1241.         Undo.RecordObject(this, "Add Outline Point");
  1242. #endif
  1243.         Vector3 np;
  1244.         if (outlinePoints.Count >= 2)
  1245.             np = Vector3.Lerp(outlinePoints[outlinePoints.Count - 1], outlinePoints[0], 0.5f);
  1246.         else if (outlinePoints.Count == 1)
  1247.             np = outlinePoints[0] + Vector3.right;
  1248.         else
  1249.             np = transform.InverseTransformPoint(new Vector3(0f, floorY, 0f));
  1250.         outlinePoints.Add(np);
  1251. #if UNITY_EDITOR
  1252.         EditorUtility.SetDirty(this);
  1253. #endif
  1254.         RegenerateLive();
  1255.     }
  1256.  
  1257.     public void RemoveLastOutlinePoint()
  1258.     {
  1259.         if (outlinePoints == null || outlinePoints.Count == 0) return;
  1260. #if UNITY_EDITOR
  1261.         Undo.RecordObject(this, "Remove Outline Point");
  1262. #endif
  1263.         outlinePoints.RemoveAt(outlinePoints.Count - 1);
  1264. #if UNITY_EDITOR
  1265.         EditorUtility.SetDirty(this);
  1266. #endif
  1267.         RegenerateLive();
  1268.     }
  1269.  
  1270.     // ─────────────── Extensions add/remove
  1271.     public void AddExtension(ExtensionSide side)
  1272.     {
  1273.         if (extensions == null) extensions = new List<MeshExtension>();
  1274. #if UNITY_EDITOR
  1275.         Undo.RecordObject(this, "Add Mesh Extension");
  1276. #endif
  1277.         extensions.Add(new MeshExtension { side = side, label = side.ToString(), extent = 3f });
  1278. #if UNITY_EDITOR
  1279.         EditorUtility.SetDirty(this);
  1280. #endif
  1281.         RegenerateLive();
  1282.     }
  1283.  
  1284.     public void RemoveLastExtension()
  1285.     {
  1286.         if (extensions == null || extensions.Count == 0) return;
  1287. #if UNITY_EDITOR
  1288.         Undo.RecordObject(this, "Remove Mesh Extension");
  1289. #endif
  1290.         extensions.RemoveAt(extensions.Count - 1);
  1291. #if UNITY_EDITOR
  1292.         EditorUtility.SetDirty(this);
  1293. #endif
  1294.         RegenerateLive();
  1295.     }
  1296.  
  1297.     /// <summary>
  1298.     /// Grid mode — "Script 1" style: one quad per (downsampled) pixel of the mask. Each
  1299.     /// corner is projected with Mesh4's UnprojectViewportToWorld, which now NEVER
  1300.     /// fails (depth-clamp instead of radial-clamp) — so no quad is rejected near the
  1301.     /// horizon, and coverage matches the mask 1:1 (minus downsampling). More polygons
  1302.     /// than Contour mode.
  1303.     /// </summary>
  1304.     Mesh GenerateGridMesh()
  1305.     {
  1306.         Color32[] pixels = ReadPixels(maskTexture, out int texW, out int texH);
  1307.         if (pixels == null || pixels.Length != texW * texH) return null;
  1308.  
  1309.         bool[] solid = new bool[texW * texH];
  1310.         int thr = Mathf.RoundToInt(threshold * 255f);
  1311.         for (int i = 0; i < solid.Length; i++)
  1312.         {
  1313.             Color32 c = pixels[i];
  1314.             solid[i] = (c.r + c.g + c.b) / 3 > thr;
  1315.         }
  1316.         if (maskDilation > 0) solid = DilateMask(solid, texW, texH, maskDilation);
  1317.         else if (maskDilation < 0) solid = ErodeMask(solid, texW, texH, -maskDilation);
  1318.  
  1319.         int yMax = Mathf.RoundToInt(horizonClampV * texH);
  1320.         for (int i = 0; i < solid.Length; i++)
  1321.             if (i / texW >= yMax) solid[i] = false;
  1322.  
  1323.         int ds = Mathf.Max(1, downsampling);
  1324.         int gw = Mathf.Max(1, texW / ds);
  1325.         int gh = Mathf.Max(1, texH / ds);
  1326.  
  1327.         // Walkable map per grid cell.
  1328.         var cell = new bool[gw * gh];
  1329.         for (int gy = 0; gy < gh; gy++)
  1330.         {
  1331.             int py0 = gy * ds, py1 = Mathf.Min(py0 + ds, texH);
  1332.             for (int gx = 0; gx < gw; gx++)
  1333.             {
  1334.                 int px0 = gx * ds, px1 = Mathf.Min(px0 + ds, texW);
  1335.                 bool walkable = false;
  1336.                 for (int py = py0; py < py1 && !walkable; py++)
  1337.                     for (int px = px0; px < px1; px++)
  1338.                         if (solid[py * texW + px]) { walkable = true; break; }
  1339.                 cell[gy * gw + gx] = walkable;
  1340.             }
  1341.         }
  1342.  
  1343.         var vertsWorld = new List<Vector3>();
  1344.         var indices = new List<int>();
  1345.  
  1346.         // Simplification in Grid mode = greedy horizontal RUN merging: instead of
  1347.         // one quad per cell, merge consecutive walkable cells in the same row into ONE
  1348.         // wide quad. simplification defines the maximum run length (in cells):
  1349.         // 0 = no merge (one quad/cell, as before); larger = fewer, wider quads.
  1350.         // Coverage remains the same — just fewer triangles.
  1351.         int maxRun = Mathf.Max(1, Mathf.RoundToInt(simplification));
  1352.         if (maxRun <= 1) maxRun = 1;
  1353.  
  1354.         for (int gy = 0; gy < gh; gy++)
  1355.         {
  1356.             float v0 = (float)gy / gh, v1 = (float)(gy + 1) / gh;
  1357.             int gx = 0;
  1358.             while (gx < gw)
  1359.             {
  1360.                 if (!cell[gy * gw + gx]) { gx++; continue; }
  1361.                 int runStart = gx;
  1362.                 int runLen = 0;
  1363.                 while (gx < gw && cell[gy * gw + gx] && runLen < maxRun) { gx++; runLen++; }
  1364.  
  1365.                 float u0 = (float)runStart / gw, u1 = (float)(runStart + runLen) / gw;
  1366.  
  1367.                 int vi = vertsWorld.Count;
  1368.                 vertsWorld.Add(UnprojectViewportToWorld(new Vector2(u0, v0)));
  1369.                 vertsWorld.Add(UnprojectViewportToWorld(new Vector2(u1, v0)));
  1370.                 vertsWorld.Add(UnprojectViewportToWorld(new Vector2(u0, v1)));
  1371.                 vertsWorld.Add(UnprojectViewportToWorld(new Vector2(u1, v1)));
  1372.  
  1373.                 indices.Add(vi); indices.Add(vi + 2); indices.Add(vi + 1);
  1374.                 indices.Add(vi + 1); indices.Add(vi + 2); indices.Add(vi + 3);
  1375.             }
  1376.         }
  1377.  
  1378.         return FinalizeMesh(vertsWorld, indices);
  1379.     }
  1380.  
  1381.     /// <summary>
  1382.     /// Contour mode (from v3): contour trace + simplify + ear-clip. Many fewer polygons
  1383.     /// than Grid mode, but Simplification slightly eats corners (Mask Dilation compensates).
  1384.     /// </summary>
  1385.     Mesh GenerateContourMesh()
  1386.     {
  1387.         if (!ComputeWalkablePolygons(out var outers, out var holes, out int w, out int h)) return null;
  1388.  
  1389.         var holeGroups = new List<List<Vector2>>[outers.Count];
  1390.         for (int i = 0; i < outers.Count; i++) holeGroups[i] = new List<List<Vector2>>();
  1391.         foreach (var hole in holes)
  1392.             for (int i = 0; i < outers.Count; i++)
  1393.                 if (PointInPolygon(hole[0], outers[i])) { holeGroups[i].Add(hole); break; }
  1394.  
  1395.         var verts2D = new List<Vector2>();
  1396.         var indices = new List<int>();
  1397.         for (int i = 0; i < outers.Count; i++)
  1398.         {
  1399.             List<Vector2> poly = MergeHoles(outers[i], holeGroups[i]);
  1400.             int baseIndex = verts2D.Count;
  1401.             List<int> tris = EarClip(poly);
  1402.             verts2D.AddRange(poly);
  1403.             for (int k = 0; k < tris.Count; k++) indices.Add(baseIndex + tris[k]);
  1404.         }
  1405.         if (indices.Count < 3) return null;
  1406.  
  1407.         // mask-px ➜ UV ➜ world floor (camera unproject)
  1408.         var vertsWorld = new List<Vector3>(verts2D.Count);
  1409.         for (int i = 0; i < verts2D.Count; i++)
  1410.         {
  1411.             Vector2 uv = new Vector2(verts2D[i].x / w, verts2D[i].y / h);
  1412.             vertsWorld.Add(UnprojectViewportToWorld(uv));
  1413.         }
  1414.  
  1415.         return FinalizeMesh(vertsWorld, indices);
  1416.     }
  1417.  
  1418.     /// <summary>
  1419.     /// Common final step: log depth, orient normals toward camera, world➜local,
  1420.     /// build Unity Mesh. Used by both generation modes.
  1421.     /// </summary>
  1422.     Mesh FinalizeMesh(List<Vector3> vertsWorld, List<int> indices)
  1423.     {
  1424.         if (indices.Count < 3 || vertsWorld.Count == 0) return null;
  1425.  
  1426.         AppendExtensions(vertsWorld, indices);
  1427.         ApplyGlobalResize(vertsWorld);
  1428.  
  1429.         if (logDepthRange)
  1430.         {
  1431.             float minZ = float.MaxValue, maxZ = float.MinValue;
  1432.             foreach (var p in vertsWorld) { minZ = Mathf.Min(minZ, p.z); maxZ = Mathf.Max(maxZ, p.z); }
  1433.             Debug.Log($"[Mesh4] Z range: {minZ:F2} → {maxZ:F2} (Δ {maxZ - minZ:F2}). Verts: {vertsWorld.Count}, Tris: {indices.Count / 3}.");
  1434.         }
  1435.  
  1436.         // Normals toward camera (Mask mode) or upward (Polygon mode, no camera),
  1437.         // so AC raycast hits the front face.
  1438.         Vector3 refDir = (warpCamera != null) ? warpCamera.transform.forward : Vector3.down;
  1439.         bool flip = false;
  1440.         for (int i = 0; i + 2 < indices.Count; i += 3)
  1441.         {
  1442.             Vector3 nrm = Vector3.Cross(vertsWorld[indices[i + 1]] - vertsWorld[indices[i]],
  1443.                                         vertsWorld[indices[i + 2]] - vertsWorld[indices[i]]);
  1444.             if (nrm.sqrMagnitude > 1e-10f) { flip = Vector3.Dot(nrm, refDir) > 0f; break; }
  1445.         }
  1446.         if (flip)
  1447.             for (int i = 0; i + 2 < indices.Count; i += 3)
  1448.             {
  1449.                 int tmp = indices[i + 1]; indices[i + 1] = indices[i + 2]; indices[i + 2] = tmp;
  1450.             }
  1451.  
  1452.         var vertsLocal = new Vector3[vertsWorld.Count];
  1453.         for (int i = 0; i < vertsWorld.Count; i++)
  1454.             vertsLocal[i] = transform.InverseTransformPoint(vertsWorld[i]);
  1455.  
  1456.         Mesh mesh = new Mesh { name = gameObject.name + "_NavMesh" };
  1457.         if (vertsLocal.Length > 65000)
  1458.             mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
  1459.         mesh.vertices = vertsLocal;
  1460.         mesh.triangles = indices.ToArray();
  1461.         mesh.RecalculateNormals();
  1462.         mesh.RecalculateBounds();
  1463.         return mesh;
  1464.     }
  1465.  
  1466.     // -------------------------------------------------------- Extensions / Resize
  1467.  
  1468.     /// <summary>World-space extensions (Grid/Contour path).</summary>
  1469.     void AppendExtensions(List<Vector3> verts, List<int> indices) => AppendExtensionsImpl(verts, indices, false);
  1470.  
  1471.     /// <summary>Local-space extensions (Polygon path).</summary>
  1472.     void AppendExtensionsLocal(List<Vector3> verts, List<int> indices) => AppendExtensionsImpl(verts, indices, doubleSidedPolygon);
  1473.  
  1474.     void AppendExtensionsImpl(List<Vector3> verts, List<int> indices, bool doubleSided)
  1475.     {
  1476.         if (extensions == null || extensions.Count == 0 || verts.Count == 0) return;
  1477.  
  1478.         float minX = float.MaxValue, maxX = float.MinValue;
  1479.         float minZ = float.MaxValue, maxZ = float.MinValue;
  1480.         double sumY = 0;
  1481.         for (int i = 0; i < verts.Count; i++)
  1482.         {
  1483.             Vector3 p = verts[i];
  1484.             if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
  1485.             if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z;
  1486.             sumY += p.y;
  1487.         }
  1488.         float baseY = (float)(sumY / verts.Count);
  1489.         float midX = (minX + maxX) * 0.5f, midZ = (minZ + maxZ) * 0.5f;
  1490.         float fullX = maxX - minX, fullZ = maxZ - minZ;
  1491.  
  1492.         foreach (var ex in extensions)
  1493.         {
  1494.             if (ex == null || !ex.enabled || ex.extent <= 0f) continue;
  1495.             float y = baseY + ex.yOffset;
  1496.             float ov = Mathf.Max(0f, ex.overlap);
  1497.             float x0, x1, z0, z1;
  1498.  
  1499.             if (ex.side == ExtensionSide.Left || ex.side == ExtensionSide.Right)
  1500.             {
  1501.                 float span = (ex.span > 0f) ? ex.span : fullZ;
  1502.                 float cz = midZ + ex.slideAlongEdge;
  1503.                 z0 = cz - span * 0.5f; z1 = cz + span * 0.5f;
  1504.                 if (ex.side == ExtensionSide.Left) { x1 = minX + ov; x0 = minX - ex.extent; }
  1505.                 else { x0 = maxX - ov; x1 = maxX + ex.extent; }
  1506.             }
  1507.             else
  1508.             {
  1509.                 float span = (ex.span > 0f) ? ex.span : fullX;
  1510.                 float cx = midX + ex.slideAlongEdge;
  1511.                 x0 = cx - span * 0.5f; x1 = cx + span * 0.5f;
  1512.                 if (ex.side == ExtensionSide.Top) { z0 = maxZ - ov; z1 = maxZ + ex.extent; }
  1513.                 else { z1 = minZ + ov; z0 = minZ - ex.extent; }
  1514.             }
  1515.  
  1516.             int b = verts.Count;
  1517.             verts.Add(new Vector3(x0, y, z0));
  1518.             verts.Add(new Vector3(x1, y, z0));
  1519.             verts.Add(new Vector3(x0, y, z1));
  1520.             verts.Add(new Vector3(x1, y, z1));
  1521.             indices.Add(b); indices.Add(b + 2); indices.Add(b + 1);
  1522.             indices.Add(b + 1); indices.Add(b + 2); indices.Add(b + 3);
  1523.             if (doubleSided)
  1524.             {
  1525.                 indices.Add(b); indices.Add(b + 1); indices.Add(b + 2);
  1526.                 indices.Add(b + 1); indices.Add(b + 3); indices.Add(b + 2);
  1527.             }
  1528.         }
  1529.     }
  1530.  
  1531.     /// <summary>
  1532.     /// Uniform scale + inset of the ENTIRE mesh around its center (XZ centroid), in-place.
  1533.     /// Y does not change. Inset is converted to an equivalent scale based on the average
  1534.     /// radius, making it predictable for arbitrary shapes.
  1535.     /// </summary>
  1536.     void ApplyGlobalResize(List<Vector3> verts)
  1537.     {
  1538.         if (verts == null || verts.Count == 0) return;
  1539.         bool doScale = Mathf.Abs(meshScaleMultiplier - 1f) > 1e-4f;
  1540.         bool doInset = Mathf.Abs(meshInset) > 1e-4f;
  1541.         if (!doScale && !doInset) return;
  1542.  
  1543.         double sx = 0, sz = 0;
  1544.         for (int i = 0; i < verts.Count; i++) { sx += verts[i].x; sz += verts[i].z; }
  1545.         float cx = (float)(sx / verts.Count), cz = (float)(sz / verts.Count);
  1546.  
  1547.         float factor = doScale ? meshScaleMultiplier : 1f;
  1548.         if (doInset)
  1549.         {
  1550.             double sumR = 0;
  1551.             for (int i = 0; i < verts.Count; i++)
  1552.             {
  1553.                 float dx = verts[i].x - cx, dz = verts[i].z - cz;
  1554.                 sumR += Mathf.Sqrt(dx * dx + dz * dz);
  1555.             }
  1556.             float meanR = (float)(sumR / verts.Count);
  1557.             if (meanR > 1e-4f) factor *= Mathf.Max(0.01f, (meanR - meshInset) / meanR);
  1558.         }
  1559.  
  1560.         for (int i = 0; i < verts.Count; i++)
  1561.         {
  1562.             Vector3 p = verts[i];
  1563.             p.x = cx + (p.x - cx) * factor;
  1564.             p.z = cz + (p.z - cz) * factor;
  1565.             verts[i] = p;
  1566.         }
  1567.     }
  1568.  
  1569.     // -------------------------------------------------------------- Pixels
  1570.  
  1571.     static Color32[] ReadPixels(Texture2D tex, out int w, out int h)
  1572.     {
  1573.         w = tex.width; h = tex.height;
  1574.         try { if (tex.isReadable) return tex.GetPixels32(); } catch { }
  1575.         RenderTexture rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
  1576.         Graphics.Blit(tex, rt);
  1577.         RenderTexture prev = RenderTexture.active;
  1578.         RenderTexture.active = rt;
  1579.         Texture2D tmp = new Texture2D(w, h, TextureFormat.RGBA32, false);
  1580.         tmp.ReadPixels(new Rect(0, 0, w, h), 0, 0);
  1581.         tmp.Apply();
  1582.         RenderTexture.active = prev;
  1583.         RenderTexture.ReleaseTemporary(rt);
  1584.         Color32[] px = tmp.GetPixels32();
  1585.         DestroySafe(tmp);
  1586.         return px;
  1587.     }
  1588.  
  1589.     // -------------------------------------------------------------- Dilation
  1590.  
  1591.     /// <summary>
  1592.     /// Separable square dilation (OR over a (2*radius+1)x(2*radius+1) window),
  1593.     /// O(w*h) via sliding-window counts. Grows the walkable area outward by
  1594.     /// 'radius' pixels and shrinks small non-walkable holes by the same amount.
  1595.     /// </summary>
  1596.     static bool[] DilateMask(bool[] src, int w, int h, int radius)
  1597.     {
  1598.         if (radius <= 0) return src;
  1599.  
  1600.         var tmp = new bool[src.Length];
  1601.         for (int y = 0; y < h; y++)
  1602.         {
  1603.             int rowBase = y * w;
  1604.             int count = 0;
  1605.             for (int i = 0; i <= radius && i < w; i++)
  1606.                 if (src[rowBase + i]) count++;
  1607.  
  1608.             for (int x = 0; x < w; x++)
  1609.             {
  1610.                 tmp[rowBase + x] = count > 0;
  1611.                 int addIdx = x + 1 + radius;
  1612.                 int remIdx = x - radius;
  1613.                 if (addIdx < w && src[rowBase + addIdx]) count++;
  1614.                 if (remIdx >= 0 && src[rowBase + remIdx]) count--;
  1615.             }
  1616.         }
  1617.  
  1618.         var dst = new bool[src.Length];
  1619.         for (int x = 0; x < w; x++)
  1620.         {
  1621.             int count = 0;
  1622.             for (int i = 0; i <= radius && i < h; i++)
  1623.                 if (tmp[i * w + x]) count++;
  1624.  
  1625.             for (int y = 0; y < h; y++)
  1626.             {
  1627.                 dst[y * w + x] = count > 0;
  1628.                 int addIdx = y + 1 + radius;
  1629.                 int remIdx = y - radius;
  1630.                 if (addIdx < h && tmp[addIdx * w + x]) count++;
  1631.                 if (remIdx >= 0 && tmp[remIdx * w + x]) count--;
  1632.             }
  1633.         }
  1634.         return dst;
  1635.     }
  1636.  
  1637.     /// <summary>
  1638.     /// Erosion (shrink) of the walkable area by 'radius' pixels. Implemented as
  1639.     /// dilation of the COMPLEMENT: erode(A) = NOT dilate(NOT A). Thus the walkable area
  1640.     /// shrinks at edges and small islands ≤ radius disappear.
  1641.     /// </summary>
  1642.     static bool[] ErodeMask(bool[] src, int w, int h, int radius)
  1643.     {
  1644.         if (radius <= 0) return src;
  1645.  
  1646.         var inv = new bool[src.Length];
  1647.         for (int i = 0; i < src.Length; i++) inv[i] = !src[i];
  1648.  
  1649.         var dilatedInv = DilateMask(inv, w, h, radius);
  1650.  
  1651.         var dst = new bool[src.Length];
  1652.         for (int i = 0; i < dst.Length; i++) dst[i] = !dilatedInv[i];
  1653.         return dst;
  1654.     }
  1655.  
  1656.     // ------------------------------------------------------------- Contours
  1657.  
  1658.     static List<List<Vector2>> TraceContours(bool[] solid, int w, int h)
  1659.     {
  1660.         bool S(int x, int y) => x >= 0 && y >= 0 && x < w && y < h && solid[y * w + x];
  1661.         int stride = w + 1;
  1662.         int P(int x, int y) => y * stride + x;
  1663.         Vector2 ToV(int p) => new Vector2(p % stride, p / stride);
  1664.  
  1665.         var edges = new Dictionary<int, List<int>>();
  1666.         void AddEdge(int a, int b)
  1667.         {
  1668.             if (!edges.TryGetValue(a, out var list)) { list = new List<int>(2); edges[a] = list; }
  1669.             list.Add(b);
  1670.         }
  1671.  
  1672.         for (int y = 0; y < h; y++)
  1673.             for (int x = 0; x < w; x++)
  1674.             {
  1675.                 if (!S(x, y)) continue;
  1676.                 if (!S(x, y - 1)) AddEdge(P(x, y), P(x + 1, y));
  1677.                 if (!S(x + 1, y)) AddEdge(P(x + 1, y), P(x + 1, y + 1));
  1678.                 if (!S(x, y + 1)) AddEdge(P(x + 1, y + 1), P(x, y + 1));
  1679.                 if (!S(x - 1, y)) AddEdge(P(x, y + 1), P(x, y));
  1680.             }
  1681.  
  1682.         var loops = new List<List<Vector2>>();
  1683.         while (edges.Count > 0)
  1684.         {
  1685.             int start = -1;
  1686.             foreach (var kv in edges) { start = kv.Key; break; }
  1687.             var loopPts = new List<int>();
  1688.             int current = start;
  1689.             float prevDX = 0f, prevDY = 0f;
  1690.             int guard = (w + 1) * (h + 1) * 4;
  1691.             bool closed = false;
  1692.  
  1693.             while (guard-- > 0)
  1694.             {
  1695.                 if (!edges.TryGetValue(current, out var outs) || outs.Count == 0) break;
  1696.                 int chosen = 0;
  1697.                 if (outs.Count > 1 && loopPts.Count > 0)
  1698.                 {
  1699.                     float best = float.NegativeInfinity;
  1700.                     Vector2 c = ToV(current);
  1701.                     for (int i = 0; i < outs.Count; i++)
  1702.                     {
  1703.                         Vector2 nxt = ToV(outs[i]);
  1704.                         float cross = prevDX * (nxt.y - c.y) - prevDY * (nxt.x - c.x);
  1705.                         if (cross > best) { best = cross; chosen = i; }
  1706.                     }
  1707.                 }
  1708.                 int next = outs[chosen];
  1709.                 outs.RemoveAt(chosen);
  1710.                 if (outs.Count == 0) edges.Remove(current);
  1711.                 loopPts.Add(current);
  1712.                 Vector2 cv = ToV(current), nv = ToV(next);
  1713.                 prevDX = nv.x - cv.x; prevDY = nv.y - cv.y;
  1714.                 current = next;
  1715.                 if (current == start) { closed = true; break; }
  1716.             }
  1717.  
  1718.             if (closed && loopPts.Count >= 3)
  1719.             {
  1720.                 var pts = new List<Vector2>(loopPts.Count);
  1721.                 foreach (int p in loopPts) pts.Add(ToV(p));
  1722.                 pts = RemoveCollinear(pts);
  1723.                 if (pts.Count >= 3) loops.Add(pts);
  1724.             }
  1725.         }
  1726.         return loops;
  1727.     }
  1728.  
  1729.     static List<Vector2> RemoveCollinear(List<Vector2> pts)
  1730.     {
  1731.         int n = pts.Count;
  1732.         var res = new List<Vector2>(n);
  1733.         for (int i = 0; i < n; i++)
  1734.         {
  1735.             Vector2 prev = pts[(i - 1 + n) % n], cur = pts[i], next = pts[(i + 1) % n];
  1736.             float cross = (cur.x - prev.x) * (next.y - cur.y) - (cur.y - prev.y) * (next.x - cur.x);
  1737.             if (Mathf.Abs(cross) > 1e-6f) res.Add(cur);
  1738.         }
  1739.         return res;
  1740.     }
  1741.  
  1742.     static List<Vector2> SimplifyClosed(List<Vector2> pts, float eps)
  1743.     {
  1744.         if (eps <= 0.001f || pts.Count < 5) return new List<Vector2>(pts);
  1745.         int far = 0; float best = -1f;
  1746.         for (int i = 1; i < pts.Count; i++)
  1747.         {
  1748.             float d = (pts[i] - pts[0]).sqrMagnitude;
  1749.             if (d > best) { best = d; far = i; }
  1750.         }
  1751.         var a = new List<Vector2>(); var b = new List<Vector2>();
  1752.         for (int i = 0; i <= far; i++) a.Add(pts[i]);
  1753.         for (int i = far; i < pts.Count; i++) b.Add(pts[i]);
  1754.         b.Add(pts[0]);
  1755.         var sa = RDP(a, eps); var sb = RDP(b, eps);
  1756.         var res = new List<Vector2>(sa);
  1757.         res.RemoveAt(res.Count - 1);
  1758.         res.AddRange(sb);
  1759.         res.RemoveAt(res.Count - 1);
  1760.         return res;
  1761.     }
  1762.  
  1763.     static List<Vector2> RDP(List<Vector2> pts, float eps)
  1764.     {
  1765.         if (pts.Count < 3) return new List<Vector2>(pts);
  1766.         var keep = new bool[pts.Count];
  1767.         keep[0] = keep[pts.Count - 1] = true;
  1768.         var stack = new Stack<(int s, int e)>();
  1769.         stack.Push((0, pts.Count - 1));
  1770.         while (stack.Count > 0)
  1771.         {
  1772.             var (s, e) = stack.Pop();
  1773.             float maxD = -1f; int idx = -1;
  1774.             Vector2 A = pts[s], B = pts[e];
  1775.             for (int i = s + 1; i < e; i++)
  1776.             {
  1777.                 float d = PointSegmentDistance(pts[i], A, B);
  1778.                 if (d > maxD) { maxD = d; idx = i; }
  1779.             }
  1780.             if (idx != -1 && maxD > eps) { keep[idx] = true; stack.Push((s, idx)); stack.Push((idx, e)); }
  1781.         }
  1782.         var res = new List<Vector2>();
  1783.         for (int i = 0; i < pts.Count; i++) if (keep[i]) res.Add(pts[i]);
  1784.         return res;
  1785.     }
  1786.  
  1787.     static float PointSegmentDistance(Vector2 p, Vector2 a, Vector2 b)
  1788.     {
  1789.         Vector2 ab = b - a;
  1790.         float len2 = ab.sqrMagnitude;
  1791.         if (len2 < 1e-10f) return (p - a).magnitude;
  1792.         float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / len2);
  1793.         return (p - (a + ab * t)).magnitude;
  1794.     }
  1795.  
  1796.     // -------------------------------------------------------------- Geometry
  1797.  
  1798.     static float Cross(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
  1799.  
  1800.     static float SignedArea(List<Vector2> p)
  1801.     {
  1802.         float a = 0f;
  1803.         for (int i = 0; i < p.Count; i++)
  1804.         {
  1805.             Vector2 c = p[i], n = p[(i + 1) % p.Count];
  1806.             a += c.x * n.y - n.x * c.y;
  1807.         }
  1808.         return a * 0.5f;
  1809.     }
  1810.  
  1811.     static bool PointInPolygon(Vector2 p, List<Vector2> poly)
  1812.     {
  1813.         bool inside = false;
  1814.         for (int i = 0, j = poly.Count - 1; i < poly.Count; j = i++)
  1815.         {
  1816.             Vector2 a = poly[i], b = poly[j];
  1817.             if ((a.y > p.y) != (b.y > p.y) &&
  1818.                 p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x)
  1819.                 inside = !inside;
  1820.         }
  1821.         return inside;
  1822.     }
  1823.  
  1824.     static bool IsReflex(List<Vector2> poly, int i)
  1825.     {
  1826.         Vector2 a = poly[(i - 1 + poly.Count) % poly.Count];
  1827.         Vector2 b = poly[i];
  1828.         Vector2 c = poly[(i + 1) % poly.Count];
  1829.         return Cross(b - a, c - b) < 0f;
  1830.     }
  1831.  
  1832.     static bool PointInTriangle(Vector2 p, Vector2 a, Vector2 b, Vector2 c)
  1833.     {
  1834.         float d1 = Cross(b - a, p - a), d2 = Cross(c - b, p - b), d3 = Cross(a - c, p - c);
  1835.         bool hasNeg = (d1 < 0f) || (d2 < 0f) || (d3 < 0f);
  1836.         bool hasPos = (d1 > 0f) || (d2 > 0f) || (d3 > 0f);
  1837.         return !(hasNeg && hasPos);
  1838.     }
  1839.  
  1840.     static bool PointInTriangleStrict(Vector2 p, Vector2 a, Vector2 b, Vector2 c)
  1841.     {
  1842.         return Cross(b - a, p - a) > 1e-9f && Cross(c - b, p - b) > 1e-9f && Cross(a - c, p - c) > 1e-9f;
  1843.     }
  1844.  
  1845.     static List<Vector2> MergeHoles(List<Vector2> outer, List<List<Vector2>> holes)
  1846.     {
  1847.         var poly = new List<Vector2>(outer);
  1848.         if (holes == null || holes.Count == 0) return poly;
  1849.         float MaxX(List<Vector2> hh) { float m = float.NegativeInfinity; foreach (var v in hh) if (v.x > m) m = v.x; return m; }
  1850.         holes.Sort((h1, h2) => MaxX(h2).CompareTo(MaxX(h1)));
  1851.         foreach (var hole in holes) poly = MergeOneHole(poly, hole);
  1852.         return poly;
  1853.     }
  1854.  
  1855.     static List<Vector2> MergeOneHole(List<Vector2> poly, List<Vector2> hole)
  1856.     {
  1857.         int mi = 0;
  1858.         for (int i = 1; i < hole.Count; i++) if (hole[i].x > hole[mi].x) mi = i;
  1859.         Vector2 M = hole[mi];
  1860.         float bestX = float.PositiveInfinity; int bestEdge = -1; Vector2 I = M;
  1861.         for (int i = 0; i < poly.Count; i++)
  1862.         {
  1863.             Vector2 a = poly[i], b = poly[(i + 1) % poly.Count];
  1864.             if ((a.y > M.y) == (b.y > M.y)) continue;
  1865.             float x = a.x + (M.y - a.y) * (b.x - a.x) / (b.y - a.y);
  1866.             if (x >= M.x - 1e-5f && x < bestX) { bestX = x; bestEdge = i; I = new Vector2(x, M.y); }
  1867.         }
  1868.         if (bestEdge < 0) bestEdge = 0;
  1869.         Vector2 ea = poly[bestEdge], eb = poly[(bestEdge + 1) % poly.Count];
  1870.         int candIdx = ea.x > eb.x ? bestEdge : (bestEdge + 1) % poly.Count;
  1871.         Vector2 Pp = poly[candIdx];
  1872.         int chosen = candIdx; float bestMetric = float.PositiveInfinity; bool blocked = false;
  1873.         for (int i = 0; i < poly.Count; i++)
  1874.         {
  1875.             if (i == candIdx) continue;
  1876.             Vector2 v = poly[i];
  1877.             if (!IsReflex(poly, i)) continue;
  1878.             if (!PointInTriangle(v, M, I, Pp)) continue;
  1879.             Vector2 d = v - M;
  1880.             float angle = Mathf.Abs(Mathf.Atan2(d.y, d.x));
  1881.             float metric = angle * 1000f + d.sqrMagnitude * 1e-6f;
  1882.             if (!blocked || metric < bestMetric) { bestMetric = metric; chosen = i; blocked = true; }
  1883.         }
  1884.         var result = new List<Vector2>(poly.Count + hole.Count + 2);
  1885.         for (int i = 0; i <= chosen; i++) result.Add(poly[i]);
  1886.         for (int k = 0; k <= hole.Count; k++) result.Add(hole[(mi + k) % hole.Count]);
  1887.         result.Add(poly[chosen]);
  1888.         for (int i = chosen + 1; i < poly.Count; i++) result.Add(poly[i]);
  1889.         return result;
  1890.     }
  1891.  
  1892.     /// <summary>
  1893.     /// Ear quality = min/max edge length ratio of triangle (a,b,c). 1 = equilateral
  1894.     /// (good), ~0 = very thin "spoke" triangle (bad). Used to prefer "square"
  1895.     /// triangles (zigzag strip) over fan from a single point.
  1896.     /// </summary>
  1897.     static float EarQuality(Vector2 a, Vector2 b, Vector2 c)
  1898.     {
  1899.         float ab = (b - a).magnitude;
  1900.         float bc = (c - b).magnitude;
  1901.         float ca = (a - c).magnitude;
  1902.         float maxEdge = Mathf.Max(ab, Mathf.Max(bc, ca));
  1903.         if (maxEdge < 1e-9f) return 0f;
  1904.         float minEdge = Mathf.Min(ab, Mathf.Min(bc, ca));
  1905.         return minEdge / maxEdge;
  1906.     }
  1907.  
  1908.     static List<int> EarClip(List<Vector2> poly)
  1909.     {
  1910.         var tris = new List<int>();
  1911.         int n = poly.Count;
  1912.         if (n < 3) return tris;
  1913.         var idx = new List<int>(n);
  1914.         if (SignedArea(poly) < 0f) for (int i = n - 1; i >= 0; i--) idx.Add(i);
  1915.         else for (int i = 0; i < n; i++) idx.Add(i);
  1916.  
  1917.         // For small/medium polygons (after Simplification), at each step choose
  1918.         // the ear with the best quality (most "square" triangle) instead of the
  1919.         // first valid one — this gives zigzag-strip triangulation in narrow paths
  1920.         // instead of spokes/fan from one point. For very large polygons, revert to
  1921.         // the faster "first valid ear" so auto-regen doesn't slow down as you move the camera.
  1922.         bool bestEarMode = n <= 220;
  1923.  
  1924.         long guard = (long)n * n + 100;
  1925.         while (idx.Count > 3 && guard-- > 0)
  1926.         {
  1927.             int bestI = -1, bI0 = 0, bI1 = 0, bI2 = 0;
  1928.             float bestQ = -1f;
  1929.  
  1930.             for (int i = 0; i < idx.Count; i++)
  1931.             {
  1932.                 int i0 = idx[(i - 1 + idx.Count) % idx.Count];
  1933.                 int i1 = idx[i];
  1934.                 int i2 = idx[(i + 1) % idx.Count];
  1935.                 Vector2 a = poly[i0], b = poly[i1], c = poly[i2];
  1936.                 if (Cross(b - a, c - b) <= 1e-9f) continue;
  1937.  
  1938.                 bool ear = true;
  1939.                 for (int j = 0; j < idx.Count; j++)
  1940.                 {
  1941.                     int vj = idx[j];
  1942.                     if (vj == i0 || vj == i1 || vj == i2) continue;
  1943.                     Vector2 p = poly[vj];
  1944.                     if (p == a || p == b || p == c) continue;
  1945.                     if (PointInTriangleStrict(p, a, b, c)) { ear = false; break; }
  1946.                 }
  1947.                 if (!ear) continue;
  1948.  
  1949.                 if (!bestEarMode) { bestI = i; bI0 = i0; bI1 = i1; bI2 = i2; break; }
  1950.  
  1951.                 float q = EarQuality(a, b, c);
  1952.                 if (q > bestQ) { bestQ = q; bestI = i; bI0 = i0; bI1 = i1; bI2 = i2; }
  1953.             }
  1954.  
  1955.             if (bestI != -1)
  1956.             {
  1957.                 tris.Add(bI0); tris.Add(bI1); tris.Add(bI2);
  1958.                 idx.RemoveAt(bestI);
  1959.                 continue;
  1960.             }
  1961.  
  1962.             // Fallback (no valid ear due to numerical noise): cut at the most "acute"
  1963.             // convex angle you find, whatever it causes.
  1964.             int besti = -1; float bestA = float.PositiveInfinity;
  1965.             for (int i = 0; i < idx.Count; i++)
  1966.             {
  1967.                 int i0 = idx[(i - 1 + idx.Count) % idx.Count];
  1968.                 int i1 = idx[i];
  1969.                 int i2 = idx[(i + 1) % idx.Count];
  1970.                 float cr = Cross(poly[i1] - poly[i0], poly[i2] - poly[i1]);
  1971.                 if (cr > 0f && cr < bestA) { bestA = cr; besti = i; }
  1972.             }
  1973.             if (besti < 0) besti = 0;
  1974.             int a0 = idx[(besti - 1 + idx.Count) % idx.Count];
  1975.             int a1 = idx[besti];
  1976.             int a2 = idx[(besti + 1) % idx.Count];
  1977.             tris.Add(a0); tris.Add(a1); tris.Add(a2);
  1978.             idx.RemoveAt(besti);
  1979.         }
  1980.         if (idx.Count == 3) { tris.Add(idx[0]); tris.Add(idx[1]); tris.Add(idx[2]); }
  1981.         return tris;
  1982.     }
  1983.  
  1984.     // -------------------------------------------------------------- Bake
  1985.  
  1986. #if UNITY_EDITOR
  1987.     public string GetDefaultBakePath()
  1988.     {
  1989.         string sceneName = (gameObject.scene.IsValid() && !string.IsNullOrEmpty(gameObject.scene.name))
  1990.             ? gameObject.scene.name : "Scene";
  1991.         return "Assets/NavMeshBakes/" + sceneName + "_" + gameObject.name + "_NavMesh.asset";
  1992.     }
  1993.  
  1994.     public void BakeToAsset()
  1995.     {
  1996.         Mesh fresh = GenerateCollisionMesh();
  1997.         if (fresh == null) { Debug.LogWarning("[Mesh4] Bake failed: no mesh generated."); return; }
  1998.         if (!AssetDatabase.IsValidFolder("Assets/NavMeshBakes"))
  1999.             AssetDatabase.CreateFolder("Assets", "NavMeshBakes");
  2000.         string path = GetDefaultBakePath();
  2001.         Mesh existing = AssetDatabase.LoadAssetAtPath<Mesh>(path);
  2002.         if (existing != null)
  2003.         {
  2004.             existing.Clear();
  2005.             existing.indexFormat = fresh.indexFormat;
  2006.             existing.vertices = fresh.vertices;
  2007.             existing.triangles = fresh.triangles;
  2008.             existing.normals = fresh.normals;
  2009.             existing.RecalculateBounds();
  2010.             EditorUtility.SetDirty(existing);
  2011.             AssetDatabase.SaveAssets();
  2012.             bakedMeshAsset = existing;
  2013.             DestroySafe(fresh);
  2014.         }
  2015.         else
  2016.         {
  2017.             AssetDatabase.CreateAsset(fresh, path);
  2018.             AssetDatabase.SaveAssets();
  2019.             bakedMeshAsset = fresh;
  2020.         }
  2021.         AssignMesh(bakedMeshAsset);
  2022.         lastHash = ComputeHash();          // prevents immediate auto-invalidation of the bake just created
  2023.         EditorUtility.SetDirty(this);
  2024.         Debug.Log("[Mesh4] Bake OK: " + path);
  2025.     }
  2026.  
  2027.     public void ClearBakedAsset(bool silent = false)
  2028.     {
  2029.         if (bakedMeshAsset != null)
  2030.         {
  2031.             string p = AssetDatabase.GetAssetPath(bakedMeshAsset);
  2032.             if (!string.IsNullOrEmpty(p)) AssetDatabase.DeleteAsset(p);
  2033.         }
  2034.         bakedMeshAsset = null;
  2035.         EditorUtility.SetDirty(this);
  2036.         Debug.Log(silent
  2037.             ? "[Mesh4] Parameter changed — bake auto-deleted, live mode."
  2038.             : "[Mesh4] Bake deleted — live mode.");
  2039.     }
  2040. #endif
  2041.  
  2042.     // -------------------------------------------------------- AC Integration
  2043.  
  2044. #if UNITY_EDITOR
  2045.     /// <summary>
  2046.     /// NavigationMesh mode: adds/configures AC.NavigationMesh (Ignore Collisions = off).
  2047.     /// Hotspot mode: adds AC.Hotspot and removes the default BoxCollider, so the
  2048.     /// clickable shape comes from this script's mesh-shaped MeshCollider.
  2049.     /// In both cases, AC.ConstantID is added (for saves).
  2050.     /// Works via reflection — if Adventure Creator is missing, just warns.
  2051.     /// </summary>
  2052.     public void SetupACComponents()
  2053.     {
  2054.         if (acTargetMode == ACTargetMode.Hotspot) SetupHotspot();
  2055.         else SetupNavigationMesh();
  2056.  
  2057.         // ── AC.ConstantID (so the object can be referenced in saves) ──
  2058.         var cidType = FindACType("ConstantID");
  2059.         if (cidType != null)
  2060.         {
  2061.             var cid = GetComponent(cidType);
  2062.             if (cid == null) cid = Undo.AddComponent(gameObject, cidType);
  2063.  
  2064.             var so = new SerializedObject(cid);
  2065.             var idProp = so.FindProperty("constantID");
  2066.             if (idProp != null && idProp.intValue == 0)
  2067.             {
  2068.                 idProp.intValue = UnityEngine.Random.Range(int.MinValue + 1, int.MaxValue);
  2069.                 so.ApplyModifiedProperties();
  2070.             }
  2071.             EditorUtility.SetDirty(cid);
  2072.         }
  2073.         else
  2074.         {
  2075.             Debug.LogWarning("[Mesh4] AC.ConstantID type not found — is Adventure Creator missing from the project?");
  2076.         }
  2077.  
  2078.         EditorUtility.SetDirty(this);
  2079.     }
  2080.  
  2081.     void SetupNavigationMesh()
  2082.     {
  2083.         var navType = FindACType("NavigationMesh");
  2084.         if (navType == null)
  2085.         {
  2086.             Debug.LogWarning("[Mesh4] AC.NavigationMesh type not found — is Adventure Creator missing from the project?");
  2087.             return;
  2088.         }
  2089.         var nav = GetComponent(navType);
  2090.         if (nav == null) nav = Undo.AddComponent(gameObject, navType);
  2091.  
  2092.         var so = new SerializedObject(nav);
  2093.         var ignoreCollisions = so.FindProperty("ignoreCollisions");
  2094.         if (ignoreCollisions != null)
  2095.         {
  2096.             ignoreCollisions.boolValue = false;
  2097.             so.ApplyModifiedProperties();
  2098.         }
  2099.         else LogMissingBoolField(so, "AC.NavigationMesh", "ignoreCollisions");
  2100.         EditorUtility.SetDirty(nav);
  2101.     }
  2102.  
  2103.     void SetupHotspot()
  2104.     {
  2105.         // The mesh-shaped MeshCollider is already here (added by RequireComponent).
  2106.         // Remove the default BoxCollider that AC may have placed on the Hotspot.
  2107.         RemoveBoxCollider();
  2108.  
  2109.         // The MeshCollider must remain non-convex & non-trigger: in 3D mode, AC
  2110.         // detects Hotspots via raycast on non-convex MeshCollider (like a Polygon
  2111.         // Collider 2D in 2D mode). Trigger + non-convex is not allowed by Unity physics.
  2112.         if (meshCollider == null) meshCollider = GetComponent<MeshCollider>();
  2113.         if (meshCollider != null)
  2114.         {
  2115.             meshCollider.convex = false;
  2116.             meshCollider.isTrigger = false;
  2117.         }
  2118.  
  2119.         var hotspotType = FindACType("Hotspot");
  2120.         if (hotspotType == null)
  2121.         {
  2122.             Debug.LogWarning("[Mesh4] AC.Hotspot type not found — is Adventure Creator missing from the project?");
  2123.             return;
  2124.         }
  2125.         var hs = GetComponent(hotspotType);
  2126.         if (hs == null)
  2127.         {
  2128.             hs = Undo.AddComponent(gameObject, hotspotType);
  2129.             Debug.Log("[Mesh4] AC.Hotspot added — set interactions in Inspector. " +
  2130.                       "The clickable shape comes from the mesh collider (not a box).");
  2131.         }
  2132.         EditorUtility.SetDirty(hs);
  2133.     }
  2134.  
  2135.     static System.Type FindACType(string name)
  2136.     {
  2137.         foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies())
  2138.         {
  2139.             var t = asm.GetType("AC." + name) ?? asm.GetType(name);
  2140.             if (t != null) return t;
  2141.         }
  2142.         return null;
  2143.     }
  2144.  
  2145.     static void LogMissingBoolField(SerializedObject so, string componentName, string wanted)
  2146.     {
  2147.         var names = new List<string>();
  2148.         var prop = so.GetIterator();
  2149.         bool enterChildren = true;
  2150.         while (prop.NextVisible(enterChildren))
  2151.         {
  2152.             enterChildren = false;
  2153.             if (prop.propertyType == SerializedPropertyType.Boolean) names.Add(prop.name);
  2154.         }
  2155.         Debug.LogWarning($"[Mesh4] Field '{wanted}' not found in {componentName} (may have been renamed in a different AC version). " +
  2156.                          $"Available bool fields: {string.Join(", ", names)}. Set it manually in the Inspector.");
  2157.     }
  2158. #endif
  2159.  
  2160.     // ---------------------------------------------------------------- Gizmos
  2161.  
  2162.     void OnDrawGizmos()
  2163.     {
  2164.         if (gizmoMode == GizmoMode.Always) DrawNavGizmos();
  2165. #if UNITY_EDITOR
  2166.         else if (gizmoMode == GizmoMode.WhenCamera && warpCamera != null && Selection.Contains(warpCamera.gameObject))
  2167.             DrawNavGizmos();
  2168. #endif
  2169.     }
  2170.  
  2171.     void OnDrawGizmosSelected()
  2172.     {
  2173.         if (gizmoMode == GizmoMode.WhenSelected || gizmoMode == GizmoMode.WhenCamera)
  2174.             DrawNavGizmos();
  2175.     }
  2176.  
  2177.     void DrawNavGizmos()
  2178.     {
  2179.         Mesh m = GetActiveMesh();
  2180.         if (m != null)
  2181.         {
  2182.             Gizmos.matrix = transform.localToWorldMatrix;
  2183.             Gizmos.color = fillColor; Gizmos.DrawMesh(m);
  2184.             Gizmos.color = wireColor; Gizmos.DrawWireMesh(m);
  2185.             Gizmos.matrix = Matrix4x4.identity;
  2186.         }
  2187.  
  2188.         Vector3 sp = GetSpawnWorldPosition();
  2189.         float gizSize;
  2190.         if (meshSourceMode == MeshSourceMode.Polygon)
  2191.         {
  2192.             Bounds b = new Bounds(sp, Vector3.zero);
  2193.             if (outlinePoints != null)
  2194.                 foreach (var p in outlinePoints)
  2195.                     b.Encapsulate(transform.TransformPoint(p));
  2196.             gizSize = Mathf.Max(0.05f, b.size.magnitude * 0.02f);
  2197.         }
  2198.         else
  2199.         {
  2200.             if (warpCamera == null) return;
  2201.             Vector3 widthRef = UnprojectViewportToWorld(new Vector2(1f, 0f)) - UnprojectViewportToWorld(new Vector2(0f, 0f));
  2202.             gizSize = Mathf.Max(0.05f, widthRef.magnitude * 0.02f);
  2203.         }
  2204.  
  2205.         Gizmos.color = Color.yellow;
  2206.         Gizmos.DrawSphere(sp, gizSize);
  2207.         Gizmos.DrawLine(sp, sp + Vector3.up * gizSize * 5f);
  2208.  
  2209.         if (showDepthLimitGizmo && meshSourceMode == MeshSourceMode.Mask && depthMode == DepthMode.Perspective && warpCamera != null)
  2210.             DrawDepthLimitGizmo();
  2211.     }
  2212.  
  2213.     /// <summary>
  2214.     /// Two orange frames at Z = camera.z ± Max Floor Distance. If the "top"
  2215.     /// (far) portion of your mesh appears in front of some hotspot, compare
  2216.     /// their positions with these frames — if the hotspot is BEHIND the frame,
  2217.     /// the mesh there is cut by Max Floor Distance; increase it.
  2218.     /// </summary>
  2219.     void DrawDepthLimitGizmo()
  2220.     {
  2221.         Vector3 camPos = warpCamera.transform.position;
  2222.         float size = Mathf.Max(maxFloorDistance, 1f);
  2223.         Color c = new Color(1f, 0.45f, 0f, 0.9f);
  2224.  
  2225.         for (int s = -1; s <= 1; s += 2)
  2226.         {
  2227.             float z = camPos.z + s * maxFloorDistance;
  2228.             Vector3 a = new Vector3(camPos.x - size, floorY, z);
  2229.             Vector3 b = new Vector3(camPos.x + size, floorY, z);
  2230.             Vector3 ta = new Vector3(camPos.x - size, floorY + size * 0.5f, z);
  2231.             Vector3 tb = new Vector3(camPos.x + size, floorY + size * 0.5f, z);
  2232.  
  2233.             Gizmos.color = c;
  2234.             Gizmos.DrawLine(a, b);
  2235.             Gizmos.DrawLine(a, ta);
  2236.             Gizmos.DrawLine(b, tb);
  2237.             Gizmos.DrawLine(ta, tb);
  2238.  
  2239. #if UNITY_EDITOR
  2240.             Handles.color = c;
  2241.             Handles.Label((a + tb) * 0.5f, $"Max Floor Distance limit (Z={z:0.#})");
  2242. #endif
  2243.         }
  2244.     }
  2245. }
  2246.  
  2247. #if UNITY_EDITOR
  2248. [CustomEditor(typeof(Mesh4))]
  2249. public class Mesh4Editor : Editor
  2250. {
  2251.     public override void OnInspectorGUI()
  2252.     {
  2253.         var m4 = (Mesh4)target;
  2254.         serializedObject.Update();
  2255.  
  2256.         // Walk all serialized properties in order; after specific fields,
  2257.         // insert the corresponding inline button.
  2258.         SerializedProperty prop = serializedObject.GetIterator();
  2259.         bool enter = true;
  2260.         while (prop.NextVisible(enter))
  2261.         {
  2262.             enter = false;
  2263.             if (prop.name == "m_Script") continue;
  2264.  
  2265.             EditorGUILayout.PropertyField(prop, true);
  2266.  
  2267.             switch (prop.name)
  2268.             {
  2269.                 // ── Polygon: buttons next to outline / init fields ──
  2270.                 case "initResolution":
  2271.                     if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
  2272.                         InlineButton($"⟳  Initialize Polygon from Mask ({m4.initResolution} points)",
  2273.                             () => m4.InitializePolygonFromMask());
  2274.                     break;
  2275.  
  2276.                 case "rectSubdivisions":
  2277.                     if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
  2278.                         InlineButton($"▭  Initialize Polygon from Camera Rect (far Z = {m4.rectFarZ:0.##})",
  2279.                             () => m4.InitializePolygonFromCameraRect());
  2280.                     break;
  2281.  
  2282.                 case "extensions":
  2283.                     EditorGUILayout.BeginHorizontal();
  2284.                     if (GUILayout.Button("+ Left")) { m4.AddExtension(Mesh4.ExtensionSide.Left); SceneView.RepaintAll(); }
  2285.                     if (GUILayout.Button("+ Right")) { m4.AddExtension(Mesh4.ExtensionSide.Right); SceneView.RepaintAll(); }
  2286.                     if (GUILayout.Button("+ Top")) { m4.AddExtension(Mesh4.ExtensionSide.Top); SceneView.RepaintAll(); }
  2287.                     if (GUILayout.Button("+ Bottom")) { m4.AddExtension(Mesh4.ExtensionSide.Bottom); SceneView.RepaintAll(); }
  2288.                     EditorGUILayout.EndHorizontal();
  2289.                     using (new EditorGUI.DisabledScope(m4.extensions == null || m4.extensions.Count == 0))
  2290.                         if (GUILayout.Button("- Last extension")) { m4.RemoveLastExtension(); SceneView.RepaintAll(); }
  2291.                     break;
  2292.  
  2293.                 case "outlinePoints":
  2294.                     if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
  2295.                     {
  2296.                         EditorGUILayout.BeginHorizontal();
  2297.                         if (GUILayout.Button("+ Point")) { m4.AddOutlinePointAfterLast(); SceneView.RepaintAll(); }
  2298.                         using (new EditorGUI.DisabledScope(m4.outlinePoints == null || m4.outlinePoints.Count == 0))
  2299.                             if (GUILayout.Button("- Last")) { m4.RemoveLastOutlinePoint(); SceneView.RepaintAll(); }
  2300.                         EditorGUILayout.EndHorizontal();
  2301.                     }
  2302.                     break;
  2303.  
  2304.                 // ── Camera tilt helper next to warpCamera ──
  2305.                 case "warpCamera":
  2306.                     DrawHorizonTiltHelper(m4);
  2307.                     break;
  2308.  
  2309.                 // ── Surface snap next to targetSurfaceZ ──
  2310.                 case "targetSurfaceZ":
  2311.                     InlineButton($"Snap Surface to Z = {m4.targetSurfaceZ:0.##}", () => m4.SnapSurfaceToZ());
  2312.                     break;
  2313.  
  2314.                 // ── Baked asset: bake / clear next to the field ──
  2315.                 case "bakedMeshAsset":
  2316.                     EditorGUILayout.BeginHorizontal();
  2317.                     if (GUILayout.Button(m4.bakedMeshAsset != null ? "Re-Bake" : "Bake to Asset"))
  2318.                         m4.BakeToAsset();
  2319.                     using (new EditorGUI.DisabledScope(m4.bakedMeshAsset == null))
  2320.                         if (GUILayout.Button("Delete Bake")) m4.ClearBakedAsset();
  2321.                     EditorGUILayout.EndHorizontal();
  2322.                     break;
  2323.  
  2324.                 // ── Spawn: buttons next to the spawn field of each mode ──
  2325.                 case "playerSpawnUV":
  2326.                     if (m4.meshSourceMode == Mesh4.MeshSourceMode.Mask) DrawSpawnButtons(m4);
  2327.                     break;
  2328.                 case "polygonSpawnPoint":
  2329.                     if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon) DrawSpawnButtons(m4);
  2330.                     break;
  2331.             }
  2332.         }
  2333.  
  2334.         serializedObject.ApplyModifiedProperties();
  2335.  
  2336.         EditorGUILayout.Space();
  2337.  
  2338.         // ── General (not tied to a single field) ──
  2339.         EditorGUILayout.BeginHorizontal();
  2340.         if (GUILayout.Button("Regenerate", GUILayout.Height(26)))
  2341.         {
  2342.             if (m4.bakedMeshAsset != null && m4.autoReBake) m4.BakeToAsset();
  2343.             else
  2344.             {
  2345.                 if (m4.bakedMeshAsset != null) m4.ClearBakedAsset(true);
  2346.                 m4.RegenerateLive();
  2347.             }
  2348.             SceneView.RepaintAll();
  2349.         }
  2350.         string acLabel = m4.acTargetMode == Mesh4.ACTargetMode.Hotspot
  2351.             ? "AC Setup (Hotspot + ConstantID)"
  2352.             : "AC Setup (NavigationMesh + ConstantID)";
  2353.         if (GUILayout.Button(acLabel, GUILayout.Height(26)))
  2354.             m4.SetupACComponents();
  2355.         EditorGUILayout.EndHorizontal();
  2356.  
  2357.         EditorGUILayout.Space();
  2358.         DrawInfoBox(m4);
  2359.     }
  2360.  
  2361.     void InlineButton(string label, System.Action action)
  2362.     {
  2363.         if (GUILayout.Button(label))
  2364.         {
  2365.             action();
  2366.             SceneView.RepaintAll();
  2367.         }
  2368.     }
  2369.  
  2370.     void DrawSpawnButtons(Mesh4 m4)
  2371.     {
  2372.         EditorGUILayout.BeginHorizontal();
  2373.         if (GUILayout.Button("🎯 Spawn ➜ Center")) { m4.CenterSpawnOnMesh(); SceneView.RepaintAll(); }
  2374.         if (GUILayout.Button("▶ Place Player")) m4.PlacePlayerAtSpawn();
  2375.         EditorGUILayout.EndHorizontal();
  2376.     }
  2377.  
  2378.     void DrawHorizonTiltHelper(Mesh4 m4)
  2379.     {
  2380.         using (new EditorGUILayout.HorizontalScope())
  2381.         {
  2382.             EditorGUILayout.PrefixLabel(new GUIContent("Horizon v (artwork)",
  2383.                 "Where you see the horizon/eye-level in the artwork (0=bottom, 1=top). " +
  2384.                 "The button calculates the camera's X rotation so its horizon falls there."));
  2385.             m4.horizonV = EditorGUILayout.Slider(m4.horizonV, 0.05f, 0.95f);
  2386.         }
  2387.         if (GUILayout.Button($"⤢  Set Camera Tilt from Horizon v (= {m4.SuggestedTiltX():0.#}°)"))
  2388.             m4.ApplyHorizonTilt();
  2389.     }
  2390.  
  2391.     void DrawInfoBox(Mesh4 m4)
  2392.     {
  2393.         string bakeInfo = m4.bakedMeshAsset != null
  2394.             ? "BAKED" + (m4.autoReBake ? " (auto re-bake ON)" : "") + " -> " + AssetDatabase.GetAssetPath(m4.bakedMeshAsset)
  2395.             : "LIVE (auto-regenerate on every change)";
  2396.  
  2397.         string modeInfo; string warn = "";
  2398.         if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
  2399.         {
  2400.             int npts = m4.outlinePoints != null ? m4.outlinePoints.Count : 0;
  2401.             modeInfo = "Generation: Polygon (" + npts + " points)";
  2402.             if (npts < 3) warn = "\n⚠ Need ≥3 Outline Points!";
  2403.         }
  2404.         else
  2405.         {
  2406.             modeInfo = "Generation: " + m4.meshGenerationMode +
  2407.                 (m4.meshGenerationMode == Mesh4.MeshGenerationMode.Grid ? $" (downsampling {m4.downsampling})" : "");
  2408.             if (m4.warpCamera == null) warn = "\n⚠ Missing warpCamera!";
  2409.         }
  2410.  
  2411.         string spawnInfo = m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon
  2412.             ? "Spawn world (" + m4.polygonSpawnPoint.x.ToString("0.##") + ", " + m4.polygonSpawnPoint.z.ToString("0.##") + ")"
  2413.             : "Spawn UV (" + m4.playerSpawnUV.x.ToString("0.##") + ", " + m4.playerSpawnUV.y.ToString("0.##") + ")";
  2414.  
  2415.         EditorGUILayout.HelpBox(
  2416.             bakeInfo + "\n" + modeInfo + "\n" +
  2417.             "Mesh: " + m4.statVerts + " verts / " + m4.statTris + " tris\n" + spawnInfo + warn,
  2418.             string.IsNullOrEmpty(warn) ? MessageType.Info : MessageType.Warning);
  2419.     }
  2420.  
  2421.     void OnSceneGUI()
  2422.     {
  2423.         var m4 = (Mesh4)target;
  2424.         if (m4.meshSourceMode == Mesh4.MeshSourceMode.Mask && m4.warpCamera == null) return;
  2425.  
  2426.         // ── Outline point handles (Polygon mode) ──
  2427.         if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon && m4.outlinePoints != null)
  2428.         {
  2429.             // Edge lines (closed loop) for visual order.
  2430.             Handles.color = new Color(0f, 1f, 0.4f, 0.8f);
  2431.             int n = m4.outlinePoints.Count;
  2432.             for (int i = 0; i < n; i++)
  2433.             {
  2434.                 Vector3 a = m4.transform.TransformPoint(m4.outlinePoints[i]);
  2435.                 Vector3 b = m4.transform.TransformPoint(m4.outlinePoints[(i + 1) % n]);
  2436.                 Handles.DrawLine(a, b);
  2437.             }
  2438.  
  2439.             for (int i = 0; i < n; i++)
  2440.             {
  2441.                 Vector3 wp = m4.transform.TransformPoint(m4.outlinePoints[i]);
  2442.                 float hs = HandleUtility.GetHandleSize(wp) * 0.09f;
  2443.                 Handles.color = Color.cyan;
  2444.                 EditorGUI.BeginChangeCheck();
  2445. #if UNITY_2022_1_OR_NEWER
  2446.                 Vector3 moved = Handles.FreeMoveHandle(wp, hs, Vector3.zero, Handles.DotHandleCap);
  2447. #else
  2448.                 Vector3 moved = Handles.FreeMoveHandle(wp, Quaternion.identity, hs, Vector3.zero, Handles.DotHandleCap);
  2449. #endif
  2450.                 if (EditorGUI.EndChangeCheck())
  2451.                 {
  2452.                     Undo.RecordObject(m4, "Move Outline Point");
  2453.                     // Keep the point on the floorY plane (world), then back to local.
  2454.                     Vector3 snapped = new Vector3(moved.x, m4.floorY, moved.z);
  2455.                     m4.outlinePoints[i] = m4.transform.InverseTransformPoint(snapped);
  2456.                     EditorUtility.SetDirty(m4);
  2457.                 }
  2458.                 Handles.Label(wp + Vector3.up * hs * 2.5f, i.ToString());
  2459.             }
  2460.         }
  2461.  
  2462.         // ── Spawn handle ──
  2463.         Vector3 pos = m4.GetSpawnWorldPosition();
  2464.         float size = HandleUtility.GetHandleSize(pos) * 0.15f;
  2465.         Handles.color = Color.yellow;
  2466.         EditorGUI.BeginChangeCheck();
  2467. #if UNITY_2022_1_OR_NEWER
  2468.         Vector3 np = Handles.FreeMoveHandle(pos, size, Vector3.zero, Handles.SphereHandleCap);
  2469. #else
  2470.         Vector3 np = Handles.FreeMoveHandle(pos, Quaternion.identity, size, Vector3.zero, Handles.SphereHandleCap);
  2471. #endif
  2472.         if (EditorGUI.EndChangeCheck())
  2473.         {
  2474.             Undo.RecordObject(m4, "Move Spawn Point");
  2475.             if (m4.meshSourceMode == Mesh4.MeshSourceMode.Polygon)
  2476.             {
  2477.                 m4.polygonSpawnPoint = new Vector3(np.x, m4.floorY, np.z);
  2478.             }
  2479.             else
  2480.             {
  2481.                 Vector2 uv = m4.WorldToUV(np);
  2482.                 m4.playerSpawnUV = new Vector2(Mathf.Clamp01(uv.x), Mathf.Clamp01(uv.y));
  2483.             }
  2484.             EditorUtility.SetDirty(m4);
  2485.         }
  2486.         Handles.Label(pos + Vector3.up * size * 2f, "Player Spawn");
  2487.     }
  2488. }
  2489. #endif
Advertisement
Add Comment
Please, Sign In to add comment