From fa5bf4d547b26f325d5929801a3159ef38a6beea Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 21 Feb 2026 03:22:51 -0500 Subject: [PATCH 01/64] fix(Building): correct door top segment positioning above roofline --- Building/Structural/WallBuilder.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index 43c4af6..d84e829 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -235,14 +235,18 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w GameObject right = PrimitiveBuilder.CreateBox($"{name}_Right", wallCenter + rightOffset, rightSize, _palette.WallColor, container.transform); ApplyWallMaterial(right); - // Top segment (above door) + // Top segment (wall above door) float topHeight = wallHeight - doorHeight; - Vector3 topSize = isVertical - ? new Vector3(_wallThickness, topHeight, doorWidth) - : new Vector3(doorWidth, topHeight, _wallThickness); - Vector3 topOffset = Vector3.up * (doorHeight / 2f + topHeight / 2f); - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + topOffset, topSize, _palette.WallColor, container.transform); - ApplyWallMaterial(top); + if (topHeight > 0f) + { + Vector3 topSize = isVertical + ? new Vector3(_wallThickness, topHeight, doorWidth) + : new Vector3(doorWidth, topHeight, _wallThickness); + float topCenterY = wallHeight / 2f - topHeight / 2f; + Vector3 topOffset = Vector3.up * topCenterY; + GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + topOffset, topSize, _palette.WallColor, container.transform); + ApplyWallMaterial(top); + } return container; } From 524cfa1a4be6768c6a4ca35526690f320eec5480 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Feb 2026 08:36:25 -0500 Subject: [PATCH 02/64] feat(Building): add TerrainClearer for clearing terrain around placed buildings --- Building/Structural/TerrainClearer.cs | 351 ++++++++++++++++++++++++++ S1MAPI.csproj | 16 ++ Utils/Constants.cs | 29 +++ 3 files changed, 396 insertions(+) create mode 100644 Building/Structural/TerrainClearer.cs diff --git a/Building/Structural/TerrainClearer.cs b/Building/Structural/TerrainClearer.cs new file mode 100644 index 0000000..8dd3163 --- /dev/null +++ b/Building/Structural/TerrainClearer.cs @@ -0,0 +1,351 @@ +using S1MAPI.Utils; +using UnityEngine; + +namespace S1MAPI.Building.Structural +{ + /// + /// Configuration for area clearing operations. + /// + public sealed class ClearingOptions + { + /// Extra padding around the clearing bounds in meters. + public float Padding { get; set; } = Constants.Terrain.DefaultClearingPadding; + + /// Whether to remove terrain tree instances. + public bool ClearTerrainTrees { get; set; } = true; + + /// Whether to remove all scene objects within the building footprint. + public bool ClearSceneObjects { get; set; } = true; + + /// Whether to remove vegetation/clutter within the padded area around the building. + public bool ClearVegetation { get; set; } = true; + + /// + /// Name patterns for vegetation and natural clutter (case-insensitive). + /// Only used by the vegetation pass. Objects inside the building footprint + /// are destroyed regardless of keywords (unless protected). + /// + public string[]? VegetationKeywords { get; set; } = Constants.Terrain.DefaultVegetationKeywords; + + /// + /// Name patterns for objects protected from footprint destruction (case-insensitive). + /// Objects matching these keywords are skipped by the footprint pass because they + /// typically extend far beyond the building and leave visible gaps when destroyed. + /// Set to null to destroy everything in the footprint. + /// + public string[]? ProtectedKeywords { get; set; } = Constants.Terrain.DefaultProtectedKeywords; + + /// + /// Transforms to preserve during clearing (e.g. the building itself). + /// All children of preserved transforms are also preserved. + /// Auto-populated from the building hierarchy when using + /// . + /// + public Transform[]? Preserved { get; set; } + + /// + /// Optional filter applied to both passes. + /// Return true to keep, false to remove. + /// + /// + /// // Keep rocks inside the building footprint: + /// options.Filter = go => go.name.Contains("Rock"); + /// + public Func? Filter { get; set; } + + /// Default clearing options. + public static ClearingOptions Default => + new(); + } + + /// + /// Clears terrain trees and scene objects from a specified world-space area. + /// Typically used to prepare a building site after positioning. + /// + public static class TerrainClearer + { + #region API + + /// + /// Clear terrain trees and scene objects within the specified world-space bounds. + /// + internal static void ClearArea(Bounds bounds, ClearingOptions? options = null) + { + options ??= ClearingOptions.Default; + float pad = options.Padding; + + int treesRemoved = 0; + int objectsRemoved = 0; + + // Terrain trees need extra padding (canopies extend beyond trunk). + if (options.ClearTerrainTrees) + { + Bounds treeBounds = bounds; + treeBounds.Expand(new Vector3(pad * 2f, pad * 2f, pad * 2f)); + treesRemoved = ClearTerrainTrees(treeBounds); + } + + // Scene objects: two passes in a single scan. + // Pass 1 (ClearSceneObjects): destroy everything in building footprint. + // Pass 2 (ClearVegetation): destroy vegetation in padded area around building. + if (options.ClearSceneObjects || options.ClearVegetation) + { + Bounds vegBounds = bounds; + vegBounds.Expand(new Vector3(pad, pad, pad)); + objectsRemoved = ClearSceneObjects(bounds, vegBounds, options); + } + + if (treesRemoved + objectsRemoved > 0) + DebugLog.Info($"[TerrainClearer] Cleared {treesRemoved} trees, {objectsRemoved} objects"); + } + + /// + /// Clear the area around a building using its transform and room size. + /// Correctly handles rotated buildings by computing world-space bounds + /// from the building's actual corners. + /// + /// The building root GameObject (must be positioned). + /// The building's room dimensions. + /// Clearing configuration. Uses defaults if null. + public static void ClearAroundBuilding( + GameObject buildingRoot, Vector3 roomSize, ClearingOptions? options = null) + { + ClearingOptions opts = options ?? ClearingOptions.Default; + + // Auto-populate Preserved with the building hierarchy if not set, + // without mutating the caller's options instance. + if (opts.Preserved == null) + { + opts = new ClearingOptions + { + Padding = opts.Padding, + ClearTerrainTrees = opts.ClearTerrainTrees, + ClearSceneObjects = opts.ClearSceneObjects, + ClearVegetation = opts.ClearVegetation, + VegetationKeywords = opts.VegetationKeywords, + ProtectedKeywords = opts.ProtectedKeywords, + Preserved = new[] { buildingRoot.transform }, + Filter = opts.Filter + }; + } + + Bounds bounds = ComputeWorldBounds(buildingRoot.transform, roomSize); + ClearArea(bounds, opts); + } + + #endregion + + #region Bounds Computation + + /// + /// Compute an axis-aligned bounding box from a building's rotated corners. + /// + private static Bounds ComputeWorldBounds(Transform building, Vector3 roomSize) + { + Vector3 min = Vector3.zero; + Vector3 max = roomSize; + + Vector3 c0 = building.TransformPoint(new Vector3(min.x, min.y, min.z)); + Vector3 c1 = building.TransformPoint(new Vector3(max.x, min.y, min.z)); + Vector3 c2 = building.TransformPoint(new Vector3(min.x, min.y, max.z)); + Vector3 c3 = building.TransformPoint(new Vector3(max.x, min.y, max.z)); + Vector3 c4 = building.TransformPoint(new Vector3(min.x, max.y, min.z)); + Vector3 c5 = building.TransformPoint(new Vector3(max.x, max.y, min.z)); + Vector3 c6 = building.TransformPoint(new Vector3(min.x, max.y, max.z)); + Vector3 c7 = building.TransformPoint(new Vector3(max.x, max.y, max.z)); + + Bounds b = new Bounds(c0, Vector3.zero); + b.Encapsulate(c1); + b.Encapsulate(c2); + b.Encapsulate(c3); + b.Encapsulate(c4); + b.Encapsulate(c5); + b.Encapsulate(c6); + b.Encapsulate(c7); + + return b; + } + + #endregion + + #region Terrain Trees + + /// Remove tree instances from all active terrains that fall within bounds. + private static int ClearTerrainTrees(Bounds bounds) + { + int totalRemoved = 0; + + foreach (Terrain terrain in Terrain.activeTerrains) + { + TerrainData data; + try + { + data = terrain.terrainData; + if (data is null) continue; + } + catch (Exception ex) + { + DebugLog.Warning($"[TerrainClearer] Could not access terrain data for '{terrain.name}': {ex.Message}"); + continue; + } + + Vector3 terrainPos = terrain.transform.position; + Vector3 terrainSize = data.size; + TreeInstance[] originalTrees = data.treeInstances; + List surviving = new List(originalTrees.Length); + int removed = 0; + + for (int i = 0; i < originalTrees.Length; i++) + { + TreeInstance tree = originalTrees[i]; + Vector3 worldPos = new Vector3( + terrainPos.x + tree.position.x * terrainSize.x, + terrainPos.y + tree.position.y * terrainSize.y, + terrainPos.z + tree.position.z * terrainSize.z + ); + + if (bounds.Contains(worldPos)) + { + removed++; + } + else + { + surviving.Add(tree); + } + } + + if (removed > 0) + { + data.treeInstances = surviving.ToArray(); + + TerrainCollider? collider = terrain.GetComponent(); + if (collider != null) + { + collider.enabled = false; + collider.enabled = true; + } + terrain.Flush(); + + DebugLog.Info($"[TerrainClearer] Removed {removed} trees from terrain '{terrain.name}'"); + } + + totalRemoved += removed; + } + + return totalRemoved; + } + + #endregion + + #region Scene Objects + + /// + /// Destroy scene objects using a two-pass approach in a single renderer scan. + /// Pass 1 (footprint): destroys all objects inside buildingBounds. + /// Pass 2 (vegetation): destroys only keyword-matched objects in the padded vegetationBounds. + /// Both passes respect Preserved and Filter. + /// + private static int ClearSceneObjects( + Bounds buildingBounds, Bounds vegetationBounds, ClearingOptions options) + { + // Use the building's XZ for the footprint but the vegetation bounds' Y range, + // so ground-level objects (y=0) under an elevated building (y=1) are caught + // while deep underground objects (sewers) are excluded. + Bounds footprintBounds = buildingBounds; + Vector3 fpMin = footprintBounds.min; + Vector3 fpMax = footprintBounds.max; + fpMin.y = vegetationBounds.min.y; + fpMax.y = vegetationBounds.max.y; + footprintBounds.SetMinMax(fpMin, fpMax); + + Renderer[] allRenderers = UnityEngine.Object.FindObjectsOfType(); + HashSet preserved = BuildPreservedSet(options); + HashSet toDestroy = new HashSet(); + + foreach (Renderer r in allRenderers) + { + if (r == null) continue; + Transform t = r.transform; + if (t.GetComponent() != null) continue; + if (preserved.Contains(t)) continue; + + bool inFootprint = footprintBounds.Contains(t.position); + bool inVegetationZone = !inFootprint && vegetationBounds.Contains(t.position); + + if (!inFootprint && !inVegetationZone) continue; + + GameObject target = ResolveLodRoot(t.gameObject); + + // Pass 1: everything inside the building footprint, except protected objects. + if (inFootprint && options.ClearSceneObjects) + { + if (options.ProtectedKeywords != null + && MatchesKeyword(target.name, options.ProtectedKeywords)) + continue; + if (options.Filter != null && options.Filter(target)) continue; + toDestroy.Add(target); + continue; + } + + // Pass 2: only vegetation in the padded zone around the building. + if (inVegetationZone && options.ClearVegetation + && options.VegetationKeywords != null + && MatchesKeyword(target.name, options.VegetationKeywords)) + { + if (options.Filter != null && options.Filter(target)) continue; + toDestroy.Add(target); + } + } + + foreach (GameObject go in toDestroy) + UnityEngine.Object.Destroy(go); + + return toDestroy.Count; + } + + /// Walk up from a renderer to its LODGroup parent, if one exists. + private static GameObject ResolveLodRoot(GameObject obj) + { + Transform current = obj.transform; + while (current.parent != null) + { + if (current.parent.GetComponent() != null) + return current.parent.gameObject; + current = current.parent; + } + return obj; + } + + /// Check if a GameObject name contains any of the given keywords (case-insensitive). + private static bool MatchesKeyword(string name, string[] keywords) + { + foreach (string keyword in keywords) + { + if (name.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + return false; + } + + /// Flatten all preserved transforms and their children into a set for fast lookup. + private static HashSet BuildPreservedSet(ClearingOptions options) + { + HashSet preserved = new HashSet(); + + if (options.Preserved == null) return preserved; + + foreach (Transform root in options.Preserved) + { + if (root == null) continue; + foreach (Transform child in root.GetComponentsInChildren()) + { + preserved.Add(child); + } + } + + return preserved; + } + + #endregion + } +} diff --git a/S1MAPI.csproj b/S1MAPI.csproj index 43e0729..71ad6b8 100644 --- a/S1MAPI.csproj +++ b/S1MAPI.csproj @@ -89,6 +89,14 @@ $(MonoAssembliesPath)\UnityEngine.InputLegacyModule.dll + + + $(MonoAssembliesPath)\UnityEngine.TerrainModule.dll + + + $(MonoAssembliesPath)\UnityEngine.TerrainPhysicsModule.dll + + $(MonoAssembliesPath)\Newtonsoft.Json.dll @@ -151,6 +159,14 @@ $(Il2CppAssembliesPath)\UnityEngine.InputLegacyModule.dll + + + $(Il2CppAssembliesPath)\UnityEngine.TerrainModule.dll + + + $(Il2CppAssembliesPath)\UnityEngine.TerrainPhysicsModule.dll + + $(Il2CppAssembliesPath)\Newtonsoft.Json.dll diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 31d1e97..6fb8c18 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -110,6 +110,35 @@ public static class Spatial public const float DefaultGridSize = 0.5f; } + /// + /// Terrain and area clearing constants. + /// + public static class Terrain + { + /// + /// Default padding around clearing bounds in meters. + /// + public const float DefaultClearingPadding = 2f; + + /// + /// Default name patterns for vegetation and natural clutter. + /// Used by the vegetation clearing pass to remove nature objects near buildings. + /// + public static readonly string[] DefaultVegetationKeywords = + { + "Rock", "Boulder", "Shrub", "Bush" + }; + + /// + /// Name patterns for objects protected from footprint destruction. + /// These typically extend far beyond the building and create visual gaps. + /// + public static readonly string[] DefaultProtectedKeywords = + { + "Road", "Sidewalk" + }; + } + /// /// GLTF file format constants /// From 0d53891629b8b6cb3aeab09bfb853f9976297319 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Feb 2026 08:59:41 -0500 Subject: [PATCH 03/64] fix(Lighting): set URP-correct light defaults for Schedule I's pipeline --- ProceduralMesh/PrimitiveBuilder.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/ProceduralMesh/PrimitiveBuilder.cs b/ProceduralMesh/PrimitiveBuilder.cs index 3ed2171..e4f99b8 100644 --- a/ProceduralMesh/PrimitiveBuilder.cs +++ b/ProceduralMesh/PrimitiveBuilder.cs @@ -254,15 +254,25 @@ public static GameObject CreateCylinder( #region Public API - Lighting /// - /// Create a point light + /// Create a point light with URP-correct defaults matching Schedule I's pipeline. /// + /// GameObject name + /// Local position relative to parent + /// Light color + /// Light range in meters + /// Light intensity + /// Optional parent transform + /// Shadow mode (default: None to match game's URP settings) + /// Render mode (default: ForcePixel for URP per-pixel lighting) public static GameObject CreatePointLight( string name, Vector3 localPosition, Color color, float range, float intensity, - Transform? parent = null) + Transform? parent = null, + LightShadows shadows = LightShadows.None, + LightRenderMode renderMode = LightRenderMode.ForcePixel) { GameObject lightObj = new GameObject(name); if (parent != null) @@ -276,8 +286,9 @@ public static GameObject CreatePointLight( light.color = color; light.range = range; light.intensity = intensity; - light.shadows = LightShadows.Soft; - + light.shadows = shadows; + light.renderMode = renderMode; + return lightObj; } From 379d04588af83bf1414f924df340a0d13c1af30f Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Feb 2026 09:51:26 -0500 Subject: [PATCH 04/64] fix(Building): clear renderer-less objects and tree rustle sounds in TerrainClearer --- Building/Structural/TerrainClearer.cs | 46 +++++++++++++++++++++++++-- Utils/Constants.cs | 2 +- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Building/Structural/TerrainClearer.cs b/Building/Structural/TerrainClearer.cs index 8dd3163..c7bb114 100644 --- a/Building/Structural/TerrainClearer.cs +++ b/Building/Structural/TerrainClearer.cs @@ -64,7 +64,7 @@ public sealed class ClearingOptions /// public static class TerrainClearer { - #region API + #region Public API /// /// Clear terrain trees and scene objects within the specified world-space bounds. @@ -218,11 +218,14 @@ private static int ClearTerrainTrees(Bounds bounds) { data.treeInstances = surviving.ToArray(); + // Force full rebuild of terrain collision data (including tree + // colliders/triggers). Toggling enabled alone may leave stale + // SpeedTree interaction data that produces rustle sounds. TerrainCollider? collider = terrain.GetComponent(); if (collider != null) { - collider.enabled = false; - collider.enabled = true; + collider.terrainData = null; + collider.terrainData = data; } terrain.Flush(); @@ -297,6 +300,43 @@ private static int ClearSceneObjects( } } + // Catch-all: scan every Transform for renderer-less objects the first pass + // missed (audio triggers, tree rustle sounds, invisible scripts, etc.). + Transform[] allTransforms = UnityEngine.Object.FindObjectsOfType(); + foreach (Transform t in allTransforms) + { + if (t == null) continue; + if (t.GetComponent() != null) continue; + if (t.GetComponent() != null) continue; // Already handled above. + if (preserved.Contains(t)) continue; + + bool inFootprint = footprintBounds.Contains(t.position); + bool inVegetationZone = !inFootprint && vegetationBounds.Contains(t.position); + + if (!inFootprint && !inVegetationZone) continue; + + GameObject target = t.gameObject; + + if (inFootprint && options.ClearSceneObjects) + { + if (options.ProtectedKeywords != null + && MatchesKeyword(target.name, options.ProtectedKeywords)) + continue; + if (options.Filter != null && options.Filter(target)) continue; + + toDestroy.Add(target); + continue; + } + + if (inVegetationZone && options.ClearVegetation + && options.VegetationKeywords != null + && MatchesKeyword(target.name, options.VegetationKeywords)) + { + if (options.Filter != null && options.Filter(target)) continue; + toDestroy.Add(target); + } + } + foreach (GameObject go in toDestroy) UnityEngine.Object.Destroy(go); diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 6fb8c18..1980b2e 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -126,7 +126,7 @@ public static class Terrain /// public static readonly string[] DefaultVegetationKeywords = { - "Rock", "Boulder", "Shrub", "Bush" + "Rock", "Boulder", "Shrub", "Bush", "Tree rustle" }; /// From 252913136e8b12613ce8ad0aaefac38cc0ef31a5 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Feb 2026 10:18:11 -0500 Subject: [PATCH 05/64] feat(Building): add onCreated callback to AddPrefab and AddSlidingDoors --- Building/BuildingBuilder.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 5c24255..721828b 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -341,10 +341,15 @@ public BuildingBuilder AddFurniture(FurnitureType type, Vector3 position, Quater /// Prefab reference from GamePrefabs /// Local position /// Local rotation + /// Optional callback invoked with the instantiated GameObject /// This builder for chaining - public BuildingBuilder AddPrefab(PrefabRef prefab, Vector3 position, Quaternion rotation) + public BuildingBuilder AddPrefab(PrefabRef prefab, Vector3 position, Quaternion rotation, Action? onCreated = null) { - GetPrefabPlacer().Place(prefab, position, rotation); + GameObject? instance = GetPrefabPlacer().Place(prefab, position, rotation); + if (instance != null) + { + onCreated?.Invoke(instance); + } return this; } @@ -354,10 +359,15 @@ public BuildingBuilder AddPrefab(PrefabRef prefab, Vector3 position, Quaternion /// Local position for doors /// Local rotation /// Text for opening hours sign + /// Optional callback invoked with the instantiated door GameObject /// This builder for chaining - public BuildingBuilder AddSlidingDoors(Vector3 position, Quaternion rotation, string openingHours = "6AM-6PM") + public BuildingBuilder AddSlidingDoors(Vector3 position, Quaternion rotation, string openingHours = "6AM-6PM", Action? onCreated = null) { - GetPrefabPlacer().PlaceSlidingDoors(position, rotation, openingHours, Materials.MetalDarkGrey); + GameObject? instance = GetPrefabPlacer().PlaceSlidingDoors(position, rotation, openingHours, Materials.MetalDarkGrey); + if (instance != null) + { + onCreated?.Invoke(instance); + } return this; } From 5955683b352a84c818cf5967f887cf60e2df45fc Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Feb 2026 10:28:54 -0500 Subject: [PATCH 06/64] fix(Building): use instance IDs for preserved set and protect road wedges in TerrainClearer --- Building/Structural/TerrainClearer.cs | 19 +++++++++++-------- Utils/Constants.cs | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Building/Structural/TerrainClearer.cs b/Building/Structural/TerrainClearer.cs index c7bb114..c33ec2d 100644 --- a/Building/Structural/TerrainClearer.cs +++ b/Building/Structural/TerrainClearer.cs @@ -262,7 +262,7 @@ private static int ClearSceneObjects( footprintBounds.SetMinMax(fpMin, fpMax); Renderer[] allRenderers = UnityEngine.Object.FindObjectsOfType(); - HashSet preserved = BuildPreservedSet(options); + HashSet preserved = BuildPreservedSet(options); HashSet toDestroy = new HashSet(); foreach (Renderer r in allRenderers) @@ -270,7 +270,7 @@ private static int ClearSceneObjects( if (r == null) continue; Transform t = r.transform; if (t.GetComponent() != null) continue; - if (preserved.Contains(t)) continue; + if (preserved.Contains(t.GetInstanceID())) continue; bool inFootprint = footprintBounds.Contains(t.position); bool inVegetationZone = !inFootprint && vegetationBounds.Contains(t.position); @@ -308,7 +308,7 @@ private static int ClearSceneObjects( if (t == null) continue; if (t.GetComponent() != null) continue; if (t.GetComponent() != null) continue; // Already handled above. - if (preserved.Contains(t)) continue; + if (preserved.Contains(t.GetInstanceID())) continue; bool inFootprint = footprintBounds.Contains(t.position); bool inVegetationZone = !inFootprint && vegetationBounds.Contains(t.position); @@ -323,7 +323,6 @@ private static int ClearSceneObjects( && MatchesKeyword(target.name, options.ProtectedKeywords)) continue; if (options.Filter != null && options.Filter(target)) continue; - toDestroy.Add(target); continue; } @@ -367,10 +366,14 @@ private static bool MatchesKeyword(string name, string[] keywords) return false; } - /// Flatten all preserved transforms and their children into a set for fast lookup. - private static HashSet BuildPreservedSet(ClearingOptions options) + /// + /// Flatten all preserved transforms and their children into a set for fast lookup. + /// Uses instance IDs because IL2CPP wrapper objects for the same native Transform + /// have different C# references, breaking HashSet reference equality. + /// + private static HashSet BuildPreservedSet(ClearingOptions options) { - HashSet preserved = new HashSet(); + HashSet preserved = new HashSet(); if (options.Preserved == null) return preserved; @@ -379,7 +382,7 @@ private static HashSet BuildPreservedSet(ClearingOptions options) if (root == null) continue; foreach (Transform child in root.GetComponentsInChildren()) { - preserved.Add(child); + preserved.Add(child.GetInstanceID()); } } diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 1980b2e..2a38f4d 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -135,7 +135,7 @@ public static class Terrain /// public static readonly string[] DefaultProtectedKeywords = { - "Road", "Sidewalk" + "Road", "Sidewalk", "Wedge" }; } From 64a4cac5f09d2e702f84df20f04b4079a34d43ad Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Feb 2026 16:37:30 -0500 Subject: [PATCH 07/64] feat(Building): add stair style system with Solid, ClosedRiser, and OpenStringer variations --- Building/BuildingBuilder.cs | 32 +++ Building/Structural/DecorBuilder.cs | 347 ++++++++++++++++++++++++++++ Building/Structural/WallBuilder.cs | 13 ++ Utils/Constants.cs | 31 +++ 4 files changed, 423 insertions(+) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 721828b..9bfcaa1 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -259,6 +259,38 @@ public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, fl return this; } + /// + /// Add stairs from ground level up to the building floor on the specified wall. + /// Supports multiple visual styles: Solid (default concrete box steps), ClosedRiser (two-tone wood with risers), + /// or OpenStringer (plank treads on diagonal stringer beams). + /// + /// Which wall the stairs attach to + /// Foundation height in meters (must match AddFoundation height) + /// Maximum height per step. Lower values create more, shallower steps. (Solid only) + /// Step width in meters (Solid only) + /// Step depth (tread) in meters. Controls how far stairs extend outward. (Solid only) + /// Optional color override — defaults to palette floor color (Solid only) + /// Optional material override — defaults to palette floor material (Solid only) + /// Visual style of stairs to generate + /// If true, topmost step is flush with floor level. If false (default), topmost step is one step below floor. (Solid only) + /// Vertical gap between foundation edge and top step. ClosedRiser/OpenStringer default to 0 (flush). + /// This builder for chaining + public BuildingBuilder AddStairs( + WallSide wall, + float foundationHeight = 2.0f, + float maxStepHeight = Constants.Spatial.DefaultMaxStepHeight, + float width = 2.5f, + float stepDepth = Constants.Spatial.DefaultStepDepth, + Color? color = null, + Material? material = null, + StairStyle style = StairStyle.Solid, + bool flushWithFloor = false, + float gap = 0f) + { + GetDecorBuilder().AddStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, style, flushWithFloor, gap); + return this; + } + /// /// Add base molding around the bottom of the building. /// diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index b0a40d2..2a3f8b0 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -1,5 +1,6 @@ using S1MAPI.Building.Config; using S1MAPI.ProceduralMesh; +using S1MAPI.Utils; using UnityEngine; namespace S1MAPI.Building.Structural @@ -16,6 +17,9 @@ public sealed class DecorBuilder private readonly Vector3 _roomSize; private readonly BuildingPalette _palette; + private float _foundationClearanceX; + private float _foundationClearanceZ; + #endregion #region Constructor @@ -210,6 +214,10 @@ public GameObject AddFoundation(float height = 2.0f, float expandX = 0f, float e float padding = 0.1f; float yOffset = -0.001f; // Avoid z-fighting with floor + // Store clearance so AddStairs can auto-clear the foundation edge + _foundationClearanceX = padding + expandX; + _foundationClearanceZ = padding + expandZ; + float width = _roomSize.x + padding * 2 + expandX * 2; float depth = _roomSize.z + padding * 2 + expandZ * 2; @@ -227,6 +235,42 @@ public GameObject AddFoundation(float height = 2.0f, float expandX = 0f, float e return container; } + /// + /// Add stairs from ground level up to the building floor on the specified wall. + /// Supports multiple visual styles: Solid (concrete box steps), ClosedRiser (two-tone wood with risers), + /// or OpenStringer (plank treads on diagonal stringer beams). + /// + /// Which wall the stairs attach to + /// Height of the foundation in meters + /// Maximum height per step. Lower values create more, shallower steps. (Solid only) + /// Step width in meters (Solid only) + /// Step depth (tread) in meters. Controls how far stairs extend outward. (Solid only) + /// Optional color override — defaults to floor color (Solid only) + /// Optional material override — defaults to floor material (Solid only) + /// Visual style of stairs to generate + /// If true, topmost step is flush with floor level. If false (default), topmost step is one step below floor. (Solid only) + /// Vertical gap between foundation edge and top step. ClosedRiser/OpenStringer default to 0 (flush). + /// The stairs container GameObject + public GameObject AddStairs( + WallSide wall, + float foundationHeight, + float maxStepHeight = Constants.Spatial.DefaultMaxStepHeight, + float width = 2.5f, + float stepDepth = Constants.Spatial.DefaultStepDepth, + Color? color = null, + Material? material = null, + StairStyle style = StairStyle.Solid, + bool flushWithFloor = false, + float gap = 0f) + { + return style switch + { + StairStyle.ClosedRiser => AddClosedRiserStairs(wall, foundationHeight, gap), + StairStyle.OpenStringer => AddOpenStringerStairs(wall, foundationHeight, gap), + _ => AddSolidStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, flushWithFloor) + }; + } + /// /// Add base molding around the bottom of the building. /// @@ -337,6 +381,309 @@ public GameObject AddCeiling(float thickness = 0.1f) #region Private Methods + private GameObject AddSolidStairs( + WallSide wall, + float foundationHeight, + float maxStepHeight, + float width, + float stepDepth, + Color? color, + Material? material, + bool flushWithFloor = false) + { + GameObject container = BuildingUtilities.CreateFolder("Stairs", _parent); + Color stepColor = color ?? _palette.FloorColor; + int count = Mathf.Max(2, Mathf.CeilToInt(foundationHeight / maxStepHeight)); + float stepRise = foundationHeight / count; + + // Default: floor acts as the final step, so we generate count-1 visible steps. + // Topmost step surface is at Y = -stepRise (one step below floor). + // flushWithFloor: topmost step surface is at Y = 0 (flush with floor level). + int visibleSteps = flushWithFloor ? count : count - 1; + + // Push steps outward past the foundation edge + // Uses clearance set by AddFoundation (padding + expand), or 0.1m default + float clearanceX = _foundationClearanceX > 0f ? _foundationClearanceX : 0.1f; + float clearanceZ = _foundationClearanceZ > 0f ? _foundationClearanceZ : 0.1f; + + for (int i = 0; i < visibleSteps; i++) + { + float height = (i + 1) * stepRise; + float yCenter = -foundationHeight + height / 2f; + bool isNorthSouth = wall == WallSide.North || wall == WallSide.South; + float clearance = isNorthSouth ? clearanceZ : clearanceX; + float perpOffset = (visibleSteps - 1 - i) * stepDepth + stepDepth / 2f + clearance; + + Vector3 position; + Vector3 size; + + switch (wall) + { + case WallSide.North: + position = new Vector3(_roomSize.x / 2f, yCenter, _roomSize.z + perpOffset); + size = new Vector3(width, height, stepDepth); + break; + case WallSide.South: + position = new Vector3(_roomSize.x / 2f, yCenter, -perpOffset); + size = new Vector3(width, height, stepDepth); + break; + case WallSide.East: + position = new Vector3(_roomSize.x + perpOffset, yCenter, _roomSize.z / 2f); + size = new Vector3(stepDepth, height, width); + break; + case WallSide.West: + position = new Vector3(-perpOffset, yCenter, _roomSize.z / 2f); + size = new Vector3(stepDepth, height, width); + break; + default: + continue; + } + + GameObject step = PrimitiveBuilder.CreateBox( + $"Step{i + 1}", position, size, stepColor, container.transform); + + Material? mat = material ?? _palette.FloorMaterial; + if (mat != null) + { + ApplyMaterial(step, mat); + } + } + + return container; + } + + private GameObject AddClosedRiserStairs(WallSide wall, float foundationHeight, float gap) + { + GameObject container = BuildingUtilities.CreateFolder("Stairs_ClosedRiser", _parent); + + // Tread planks and riser faces use separate materials for two-tone look + Material? treadMaterial = MaterialPresets.FindExistingMaterial(Constants.Materials.TreadWoodName); + Material? riserMaterial = MaterialPresets.FindExistingMaterial(Constants.Materials.RiserWoodName); + + Color riserColor = new Color(0.85f, 0.80f, 0.72f); + Color treadColor = new Color(0.45f, 0.35f, 0.25f); + + float width = 2.5f; + float stepDepth = Constants.Spatial.DefaultStepDepth; + float treadThickness = 0.05f; + int count = Mathf.Max(2, Mathf.CeilToInt(foundationHeight / Constants.Spatial.DefaultMaxStepHeight)); + float stepRise = foundationHeight / count; + // ClosedRiser: top step is flush with floor + int visibleSteps = count; + + float clearanceX = _foundationClearanceX > 0f ? _foundationClearanceX : 0.1f; + float clearanceZ = _foundationClearanceZ > 0f ? _foundationClearanceZ : 0.1f; + + for (int i = 0; i < visibleSteps; i++) + { + float height = (i + 1) * stepRise; + float yCenter = -foundationHeight + height / 2f - gap; + bool isNorthSouth = wall == WallSide.North || wall == WallSide.South; + float clearance = isNorthSouth ? clearanceZ : clearanceX; + float perpOffset = (visibleSteps - 1 - i) * stepDepth + stepDepth / 2f + clearance; + + // Tread sits ON TOP of riser (not embedded) to avoid z-fighting + float treadY = -foundationHeight + height + treadThickness / 2f - gap; + + Vector3 riserPos, riserSize, treadPos, treadSize; + + // Tread overhang: slight lip past riser face + float treadOverhangDepth = 0.04f; // 4cm total depth overhang (2cm front + back) + float treadOverhangWidth = 0.06f; // 6cm total width overhang (3cm per side) + + switch (wall) + { + case WallSide.North: + riserPos = new Vector3(_roomSize.x / 2f, yCenter, _roomSize.z + perpOffset); + riserSize = new Vector3(width, height, stepDepth); + treadPos = new Vector3(_roomSize.x / 2f, treadY, _roomSize.z + perpOffset); + treadSize = new Vector3(width + treadOverhangWidth, treadThickness, stepDepth + treadOverhangDepth); + break; + case WallSide.South: + riserPos = new Vector3(_roomSize.x / 2f, yCenter, -perpOffset); + riserSize = new Vector3(width, height, stepDepth); + treadPos = new Vector3(_roomSize.x / 2f, treadY, -perpOffset); + treadSize = new Vector3(width + treadOverhangWidth, treadThickness, stepDepth + treadOverhangDepth); + break; + case WallSide.East: + riserPos = new Vector3(_roomSize.x + perpOffset, yCenter, _roomSize.z / 2f); + riserSize = new Vector3(stepDepth, height, width); + treadPos = new Vector3(_roomSize.x + perpOffset, treadY, _roomSize.z / 2f); + treadSize = new Vector3(stepDepth + treadOverhangDepth, treadThickness, width + treadOverhangWidth); + break; + case WallSide.West: + riserPos = new Vector3(-perpOffset, yCenter, _roomSize.z / 2f); + riserSize = new Vector3(stepDepth, height, width); + treadPos = new Vector3(-perpOffset, treadY, _roomSize.z / 2f); + treadSize = new Vector3(stepDepth + treadOverhangDepth, treadThickness, width + treadOverhangWidth); + break; + default: + continue; + } + + // Riser body (light brown wood) + GameObject riser = PrimitiveBuilder.CreateBox( + $"Riser{i + 1}", riserPos, riserSize, riserColor, container.transform); + if (riserMaterial != null) ApplyMaterial(riser, riserMaterial); + + // Tread plank on top (dark wood, slight overhang) + GameObject tread = PrimitiveBuilder.CreateBox( + $"Tread{i + 1}", treadPos, treadSize, treadColor, container.transform); + if (treadMaterial != null) ApplyMaterial(tread, treadMaterial); + } + + DebugLog.Info($"[DecorBuilder] ClosedRiser stairs: {visibleSteps} steps, gap={gap:F2}, treadMat={treadMaterial?.name ?? "fallback"}, riserMat={riserMaterial?.name ?? "fallback"}"); + return container; + } + + private GameObject AddOpenStringerStairs(WallSide wall, float foundationHeight, float gap) + { + GameObject container = BuildingUtilities.CreateFolder("Stairs_OpenStringer", _parent); + + // Separate materials for treads and stringer beams + Material? strinTreadMaterial = MaterialPresets.FindExistingMaterial(Constants.Materials.StringerTreadWoodName); + Color woodColor = new Color(0.55f, 0.45f, 0.35f); + Material? strinBeamMaterial = MaterialPresets.FindExistingMaterial(Constants.Materials.StringerBeamWoodName); + + float width = 3.0f; + float stepDepth = Constants.Spatial.DefaultStepDepth; + float treadThickness = 0.07f; + float beamWidth = 0.25f; // Stringer beam cross-section width (4x4 post style) + float beamHeight = 0.25f; // Stringer beam cross-section height (4x4 post style) + int count = Mathf.Max(2, Mathf.CeilToInt(foundationHeight / Constants.Spatial.DefaultMaxStepHeight)); + float stepRise = foundationHeight / count; + // Top step sits one rise below floor level (the foundation/floor is the final surface) + int visibleSteps = count - 1; + + float clearanceX = _foundationClearanceX > 0f ? _foundationClearanceX : 0.1f; + float clearanceZ = _foundationClearanceZ > 0f ? _foundationClearanceZ : 0.1f; + bool isNorthSouth = wall == WallSide.North || wall == WallSide.South; + float clearance = isNorthSouth ? clearanceZ : clearanceX; + + // --- Treads (flat planks, no solid risers) --- + float treadOverhang = 0.20f; // Treads extend past the stringer beams on each side + for (int i = 0; i < visibleSteps; i++) + { + float stepTop = -foundationHeight + (i + 1) * stepRise - gap; + float treadY = stepTop + treadThickness / 2f; + float perpOffset = (visibleSteps - 1 - i) * stepDepth + stepDepth / 2f + clearance; + + Vector3 treadPos, treadSize; + switch (wall) + { + case WallSide.North: + treadPos = new Vector3(_roomSize.x / 2f, treadY, _roomSize.z + perpOffset); + treadSize = new Vector3(width + treadOverhang, treadThickness, stepDepth + 0.04f); + break; + case WallSide.South: + treadPos = new Vector3(_roomSize.x / 2f, treadY, -perpOffset); + treadSize = new Vector3(width + treadOverhang, treadThickness, stepDepth + 0.04f); + break; + case WallSide.East: + treadPos = new Vector3(_roomSize.x + perpOffset, treadY, _roomSize.z / 2f); + treadSize = new Vector3(stepDepth + 0.04f, treadThickness, width + treadOverhang); + break; + case WallSide.West: + treadPos = new Vector3(-perpOffset, treadY, _roomSize.z / 2f); + treadSize = new Vector3(stepDepth + 0.04f, treadThickness, width + treadOverhang); + break; + default: + continue; + } + + GameObject tread = PrimitiveBuilder.CreateBox( + $"Tread{i + 1}", treadPos, treadSize, woodColor, container.transform); + if (strinTreadMaterial != null) ApplyMaterial(tread, strinTreadMaterial); + } + + // --- Stringer beams (diagonal supports on left and right sides) --- + // Top end embeds into the foundation (at floor level), bottom end sinks into the ground. + float bottomExtend = 0.30f; + + // Stringer runs from inside the foundation down to the ground + float stringerBottomY = -foundationHeight - gap; + // Push the top anchor well below floor level and behind the wall so the beam is fully hidden + float stringerTopY = -gap + stepRise; // One step above floor (hidden inside foundation) + float topPerp = -beamHeight; // Behind the wall surface (into foundation) + float bottomPerp = (visibleSteps - 1) * stepDepth + stepDepth / 2f + clearance; + float stringerRun = bottomPerp - topPerp; + float stringerRise = stringerTopY - stringerBottomY; + + // Stringer angle + float angleRad = Mathf.Atan2(stringerRise, stringerRun); + float angleDeg = angleRad * Mathf.Rad2Deg; + + float baseLength = Mathf.Sqrt(stringerRise * stringerRise + stringerRun * stringerRun); + + // The beam's actual endpoints along the slope: + // - Top end: pull back 2 step rises from the anchor to hide inside foundation + float topTrim = stepRise * 3.0f; + // - Bottom end: extend into the ground + float stringerLength = baseLength + bottomExtend - topTrim; + + // Compute the actual top and bottom endpoints of the trimmed/extended beam + // Direction along slope (from top toward bottom): perp increases, Y decreases + float dirPerp = Mathf.Cos(angleRad); + float dirY = Mathf.Sin(angleRad); + + // Actual top end (trimmed): move topTrim along slope from the original top anchor + float actualTopPerp = topPerp + topTrim * dirPerp; + float actualTopY = stringerTopY - topTrim * dirY; + // Actual bottom end (extended): move bottomExtend along slope past original bottom + float actualBottomPerp = bottomPerp + bottomExtend * dirPerp; + float actualBottomY = stringerBottomY - bottomExtend * dirY; + + // Midpoint is simply the average of the actual endpoints + float midPerp = (actualTopPerp + actualBottomPerp) / 2f; + float midY = (actualTopY + actualBottomY) / 2f; + + // Left/right offset: beams sit inside the tread width, treads overhang past them + float sideOffset = width / 2f - beamWidth; + + for (int side = 0; side < 2; side++) + { + float signedOffset = (side == 0) ? -sideOffset : sideOffset; + + Vector3 beamPos; + // Use Y as the long axis so the wood grain texture runs along the beam length + Vector3 beamSize = new Vector3(beamWidth, stringerLength, beamHeight); + Quaternion beamRot; + + switch (wall) + { + case WallSide.North: + beamPos = new Vector3(_roomSize.x / 2f + signedOffset, midY, _roomSize.z + midPerp); + beamRot = Quaternion.Euler(angleDeg + 90f, 0f, 0f); + break; + case WallSide.South: + beamPos = new Vector3(_roomSize.x / 2f + signedOffset, midY, -midPerp); + beamRot = Quaternion.Euler(-angleDeg - 90f, 0f, 0f); + break; + case WallSide.East: + beamPos = new Vector3(_roomSize.x + midPerp, midY, _roomSize.z / 2f + signedOffset); + beamSize = new Vector3(beamHeight, stringerLength, beamWidth); + beamRot = Quaternion.Euler(0f, 0f, -angleDeg - 90f); + break; + case WallSide.West: + beamPos = new Vector3(-midPerp, midY, _roomSize.z / 2f + signedOffset); + beamSize = new Vector3(beamHeight, stringerLength, beamWidth); + beamRot = Quaternion.Euler(0f, 0f, angleDeg + 90f); + break; + default: + continue; + } + + GameObject beam = PrimitiveBuilder.CreateBox( + $"Stringer{side + 1}", beamPos, beamSize, woodColor, container.transform); + beam.transform.rotation = _parent.rotation * beamRot; + if (strinBeamMaterial != null) ApplyMaterial(beam, strinBeamMaterial); + else if (strinTreadMaterial != null) ApplyMaterial(beam, strinTreadMaterial); + } + + DebugLog.Info($"[DecorBuilder] OpenStringer stairs: {visibleSteps} treads + 2 stringers, gap={gap:F2}, treadMat={strinTreadMaterial?.name ?? "fallback"}, beamMat={strinBeamMaterial?.name ?? "fallback"}"); + return container; + } + private static void ApplyMaterial(GameObject obj, Material material) { Renderer r = obj.GetComponent(); diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index d84e829..8754f80 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -20,6 +20,19 @@ public enum WallSide West } + /// + /// Visual style for generated stairs. + /// + public enum StairStyle + { + /// Solid concrete box steps (default). + Solid, + /// Two-tone wood stairs with risers and tread planks (closed riser style). + ClosedRiser, + /// Open plank treads on diagonal stringer beams (open stringer style). + OpenStringer + } + /// /// Type of opening in a wall. /// diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 2a38f4d..98bbfbc 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -86,6 +86,26 @@ public static class Materials /// Alpha value for general glass materials /// public const float GlassAlpha = 0.3f; + + /// + /// Scene material name for closed riser tread planks. + /// + public const string TreadWoodName = "wood brown"; + + /// + /// Scene material name for closed riser faces. + /// + public const string RiserWoodName = "wood_beige"; + + /// + /// Scene material name for open stringer tread planks. + /// + public const string StringerTreadWoodName = "mansion_brownwood_mat"; + + /// + /// Scene material name for open stringer diagonal beams. + /// + public const string StringerBeamWoodName = "wood brown"; } /// @@ -108,6 +128,17 @@ public static class Spatial /// Default grid cell size for snapping operations /// public const float DefaultGridSize = 0.5f; + + /// + /// Default maximum step height for generated stairs. + /// Kept below typical CharacterController stepOffset (~0.3m) for reliable climbing. + /// + public const float DefaultMaxStepHeight = 0.20f; + + /// + /// Default step depth (tread) for generated stairs in meters. + /// + public const float DefaultStepDepth = 0.3f; } /// From 1eeb58a9606191c83c3cf6eb4dbac79681a99d97 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Feb 2026 17:06:17 -0500 Subject: [PATCH 08/64] fix(Building): gap base molding around door openings --- Building/BuildingBuilder.cs | 27 ++++-- Building/Structural/DecorBuilder.cs | 124 +++++++++++++++++++--------- 2 files changed, 106 insertions(+), 45 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 9bfcaa1..d0134a6 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -39,6 +39,12 @@ public sealed class BuildingBuilder private DecorBuilder? _decorBuilder; private PrefabPlacer? _prefabPlacer; + // Stored wall openings for cross-builder communication (e.g., base molding gap) + private WallOpening? _northOpening; + private WallOpening? _southOpening; + private WallOpening? _eastOpening; + private WallOpening? _westOpening; + #endregion #region Constructor @@ -178,12 +184,17 @@ public BuildingBuilder AddWalls( if (material != null) palette.WallMaterial = material; } + _northOpening = northDoor ? WallOpening.Door() : null; + _southOpening = southDoor ? WallOpening.Door() : null; + _eastOpening = eastWindow ? WallOpening.Window() : null; + _westOpening = westWindow ? WallOpening.Window() : null; + var builder = GetWallBuilder(palette); builder.BuildWalls( - northOpening: northDoor ? WallOpening.Door() : null, - southOpening: southDoor ? WallOpening.Door() : null, - eastOpening: eastWindow ? WallOpening.Window() : null, - westOpening: westWindow ? WallOpening.Window() : null); + northOpening: _northOpening, + southOpening: _southOpening, + eastOpening: _eastOpening, + westOpening: _westOpening); return this; } @@ -202,6 +213,11 @@ public BuildingBuilder AddWalls( WallOpening? east = null, WallOpening? west = null) { + _northOpening = north; + _southOpening = south; + _eastOpening = east; + _westOpening = west; + GetWallBuilder().BuildWalls(north, south, east, west); return this; } @@ -300,7 +316,8 @@ public BuildingBuilder AddStairs( /// This builder for chaining public BuildingBuilder AddBaseMolding(float height = 0.3f, float depth = 0.1f, Material? material = null) { - GetDecorBuilder().AddBaseMolding(height, depth, material); + GetDecorBuilder().AddBaseMolding(height, depth, material, + _northOpening, _southOpening, _eastOpening, _westOpening); return this; } diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index 2a3f8b0..374d92f 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -273,64 +273,51 @@ public GameObject AddStairs( /// /// Add base molding around the bottom of the building. + /// Automatically gaps around door openings so the molding does not clip through door frames. /// /// Molding height in meters /// Molding depth in meters /// Optional material override + /// North wall opening (doors create a gap) + /// South wall opening (doors create a gap) + /// East wall opening (doors create a gap) + /// West wall opening (doors create a gap) /// The molding container GameObject - public GameObject AddBaseMolding(float height = 0.3f, float depth = 0.1f, Material? material = null) + public GameObject AddBaseMolding( + float height = 0.3f, float depth = 0.1f, Material? material = null, + WallOpening? northOpening = null, WallOpening? southOpening = null, + WallOpening? eastOpening = null, WallOpening? westOpening = null) { float halfWidth = _roomSize.x / 2f; float halfDepth = _roomSize.z / 2f; GameObject container = BuildingUtilities.CreateFolder("BaseMolding", _parent); Color color = _palette.TrimColor; - - // Back (North) - GameObject back = PrimitiveBuilder.CreateBox( - "BaseMolding_North", + Material? mat = material ?? _palette.TrimMaterial; + + // North (extends along X) + CreateMoldingSegments("BaseMolding_North", new Vector3(halfWidth, height / 2f, _roomSize.z + depth / 2f), new Vector3(_roomSize.x + depth * 2f, height, depth), - color, - container.transform - ); + _roomSize.x + depth * 2f, false, northOpening, height, color, mat, container); - // Left (West) - GameObject left = PrimitiveBuilder.CreateBox( - "BaseMolding_West", - new Vector3(-depth / 2f, height / 2f, halfDepth), - new Vector3(depth, height, _roomSize.z), - color, - container.transform - ); + // South (extends along X) + CreateMoldingSegments("BaseMolding_South", + new Vector3(halfWidth, height / 2f, -depth / 2f), + new Vector3(_roomSize.x + depth * 2f, height, depth), + _roomSize.x + depth * 2f, false, southOpening, height, color, mat, container); - // Right (East) - GameObject right = PrimitiveBuilder.CreateBox( - "BaseMolding_East", + // East (extends along Z) + CreateMoldingSegments("BaseMolding_East", new Vector3(_roomSize.x + depth / 2f, height / 2f, halfDepth), new Vector3(depth, height, _roomSize.z), - color, - container.transform - ); - - // Front (South) - GameObject front = PrimitiveBuilder.CreateBox( - "BaseMolding_South", - new Vector3(halfWidth, height / 2f, -depth / 2f), - new Vector3(_roomSize.x + depth * 2f, height, depth), - color, - container.transform - ); + _roomSize.z, true, eastOpening, height, color, mat, container); - // Apply material - Material? mat = material ?? _palette.TrimMaterial; - if (mat != null) - { - ApplyMaterial(back, mat); - ApplyMaterial(left, mat); - ApplyMaterial(right, mat); - ApplyMaterial(front, mat); - } + // West (extends along Z) + CreateMoldingSegments("BaseMolding_West", + new Vector3(-depth / 2f, height / 2f, halfDepth), + new Vector3(depth, height, _roomSize.z), + _roomSize.z, true, westOpening, height, color, mat, container); return container; } @@ -684,6 +671,63 @@ private GameObject AddOpenStringerStairs(WallSide wall, float foundationHeight, return container; } + /// + /// Creates a molding strip, splitting it into two segments if a door opening is present. + /// + private void CreateMoldingSegments( + string name, Vector3 center, Vector3 size, float wallLength, + bool isZAxis, WallOpening? opening, float moldingHeight, + Color color, Material? material, GameObject container) + { + bool hasDoorGap = opening != null + && opening.Type == WallOpeningType.Door + && opening.BottomOffset < moldingHeight; + + if (!hasDoorGap) + { + GameObject strip = PrimitiveBuilder.CreateBox(name, center, size, color, container.transform); + if (material != null) ApplyMaterial(strip, material); + return; + } + + float segmentLength = (wallLength - opening!.Width) / 2f; + if (segmentLength <= 0f) return; + + // Offset from strip center to each segment center + float offsetFromCenter = (opening.Width + segmentLength) / 2f; + + if (isZAxis) + { + // Strip runs along Z (East/West walls) + Vector3 segSize = new Vector3(size.x, size.y, segmentLength); + Vector3 lowZ = center + Vector3.back * offsetFromCenter; + Vector3 highZ = center + Vector3.forward * offsetFromCenter; + + GameObject left = PrimitiveBuilder.CreateBox($"{name}_L", lowZ, segSize, color, container.transform); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_R", highZ, segSize, color, container.transform); + if (material != null) + { + ApplyMaterial(left, material); + ApplyMaterial(right, material); + } + } + else + { + // Strip runs along X (North/South walls) + Vector3 segSize = new Vector3(segmentLength, size.y, size.z); + Vector3 lowX = center + Vector3.left * offsetFromCenter; + Vector3 highX = center + Vector3.right * offsetFromCenter; + + GameObject left = PrimitiveBuilder.CreateBox($"{name}_L", lowX, segSize, color, container.transform); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_R", highX, segSize, color, container.transform); + if (material != null) + { + ApplyMaterial(left, material); + ApplyMaterial(right, material); + } + } + } + private static void ApplyMaterial(GameObject obj, Material material) { Renderer r = obj.GetComponent(); From f86e2c969a4709e5c02fa43090099e6b2a468469 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Feb 2026 17:17:10 -0500 Subject: [PATCH 09/64] fix(Building): position base molding to protrude past walls like roof trim --- Building/Structural/DecorBuilder.cs | 34 +++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index 374d92f..b12ef36 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -290,34 +290,36 @@ public GameObject AddBaseMolding( { float halfWidth = _roomSize.x / 2f; float halfDepth = _roomSize.z / 2f; + float wallThickness = 0.2f; + float trimDepth = wallThickness + depth; GameObject container = BuildingUtilities.CreateFolder("BaseMolding", _parent); Color color = _palette.TrimColor; Material? mat = material ?? _palette.TrimMaterial; - // North (extends along X) + // North (extends along X, centered on wall surface) CreateMoldingSegments("BaseMolding_North", - new Vector3(halfWidth, height / 2f, _roomSize.z + depth / 2f), - new Vector3(_roomSize.x + depth * 2f, height, depth), - _roomSize.x + depth * 2f, false, northOpening, height, color, mat, container); + new Vector3(halfWidth, height / 2f, _roomSize.z), + new Vector3(_roomSize.x + trimDepth, height, trimDepth), + _roomSize.x + trimDepth, false, northOpening, height, color, mat, container); - // South (extends along X) + // South (extends along X, centered on wall surface) CreateMoldingSegments("BaseMolding_South", - new Vector3(halfWidth, height / 2f, -depth / 2f), - new Vector3(_roomSize.x + depth * 2f, height, depth), - _roomSize.x + depth * 2f, false, southOpening, height, color, mat, container); + new Vector3(halfWidth, height / 2f, 0f), + new Vector3(_roomSize.x + trimDepth, height, trimDepth), + _roomSize.x + trimDepth, false, southOpening, height, color, mat, container); - // East (extends along Z) + // East (extends along Z, centered on wall surface) CreateMoldingSegments("BaseMolding_East", - new Vector3(_roomSize.x + depth / 2f, height / 2f, halfDepth), - new Vector3(depth, height, _roomSize.z), - _roomSize.z, true, eastOpening, height, color, mat, container); + new Vector3(_roomSize.x, height / 2f, halfDepth), + new Vector3(trimDepth, height, _roomSize.z - trimDepth), + _roomSize.z - trimDepth, true, eastOpening, height, color, mat, container); - // West (extends along Z) + // West (extends along Z, centered on wall surface) CreateMoldingSegments("BaseMolding_West", - new Vector3(-depth / 2f, height / 2f, halfDepth), - new Vector3(depth, height, _roomSize.z), - _roomSize.z, true, westOpening, height, color, mat, container); + new Vector3(0f, height / 2f, halfDepth), + new Vector3(trimDepth, height, _roomSize.z - trimDepth), + _roomSize.z - trimDepth, true, westOpening, height, color, mat, container); return container; } From c7fff2aca352fc419754411953c07daac49cc8cc Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Feb 2026 18:04:46 -0500 Subject: [PATCH 10/64] feat(Building): add opt-in door frames with wall inset for clean fit --- Building/BuildingBuilder.cs | 13 +++ Building/Structural/DecorBuilder.cs | 155 ++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index d0134a6..ca1deda 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -307,6 +307,19 @@ public BuildingBuilder AddStairs( return this; } + /// + /// Add trim-style door frames around door openings. + /// Must be called after AddWalls. + /// + /// Optional material override + /// This builder for chaining + public BuildingBuilder AddDoorFrames(Material? material = null) + { + GetDecorBuilder().AddDoorFrames( + _northOpening, _southOpening, _eastOpening, _westOpening, material); + return this; + } + /// /// Add base molding around the bottom of the building. /// diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index b12ef36..e436c77 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -271,6 +271,67 @@ public GameObject AddStairs( }; } + /// + /// Add trim-style door frames (left jamb, right jamb, header) around door openings. + /// Frames use palette trim color/material and protrude slightly past the wall surface. + /// + /// North wall opening + /// South wall opening + /// East wall opening + /// West wall opening + /// Optional material override + /// The door frames container GameObject + public GameObject AddDoorFrames( + WallOpening? northOpening = null, WallOpening? southOpening = null, + WallOpening? eastOpening = null, WallOpening? westOpening = null, + Material? material = null) + { + GameObject container = BuildingUtilities.CreateFolder("DoorFrames", _parent); + + float wallThickness = 0.2f; + float frameWidth = 0.12f; + float frameProtrusion = 0.1f; + float trimDepth = wallThickness + frameProtrusion; + + Color color = _palette.TrimColor; + Material? mat = material ?? _palette.TrimMaterial; + + // Inset wall segments and create frames for each door opening + if (northOpening?.Type == WallOpeningType.Door) + { + InsetDoorWallSegments("NorthWall", frameWidth, false); + CreateDoorFrame("DoorFrame_North", + new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, _roomSize.z), + _roomSize.y, northOpening, false, frameWidth, trimDepth, color, mat, container); + } + + if (southOpening?.Type == WallOpeningType.Door) + { + InsetDoorWallSegments("SouthWall", frameWidth, false); + CreateDoorFrame("DoorFrame_South", + new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, 0f), + _roomSize.y, southOpening, false, frameWidth, trimDepth, color, mat, container); + } + + if (eastOpening?.Type == WallOpeningType.Door) + { + InsetDoorWallSegments("EastWall", frameWidth, true); + CreateDoorFrame("DoorFrame_East", + new Vector3(_roomSize.x, _roomSize.y / 2f, _roomSize.z / 2f), + _roomSize.y, eastOpening, true, frameWidth, trimDepth, color, mat, container); + } + + if (westOpening?.Type == WallOpeningType.Door) + { + InsetDoorWallSegments("WestWall", frameWidth, true); + CreateDoorFrame("DoorFrame_West", + new Vector3(0f, _roomSize.y / 2f, _roomSize.z / 2f), + _roomSize.y, westOpening, true, frameWidth, trimDepth, color, mat, container); + } + + return container; + } + /// /// Add base molding around the bottom of the building. /// Automatically gaps around door openings so the molding does not clip through door frames. @@ -730,6 +791,100 @@ private void CreateMoldingSegments( } } + /// + /// Shrinks wall segments around a door opening to make room for the door frame. + /// The side segments pull back laterally and the top segment pulls up, + /// leaving gaps that the door frame casings fill exactly. + /// + private void InsetDoorWallSegments(string wallName, float inset, bool isVertical) + { + Transform? wallContainer = _parent.Find($"Walls/{wallName}"); + if (wallContainer == null) return; + + Transform? left = wallContainer.Find($"{wallName}_Left"); + Transform? right = wallContainer.Find($"{wallName}_Right"); + Transform? top = wallContainer.Find($"{wallName}_Top"); + + // Shrink side segments away from the door opening + if (left != null) + { + Vector3 scale = left.localScale; + Vector3 pos = left.localPosition; + if (isVertical) { scale.z -= inset; pos.z -= inset / 2f; } + else { scale.x -= inset; pos.x -= inset / 2f; } + left.localScale = scale; + left.localPosition = pos; + } + + if (right != null) + { + Vector3 scale = right.localScale; + Vector3 pos = right.localPosition; + if (isVertical) { scale.z -= inset; pos.z += inset / 2f; } + else { scale.x -= inset; pos.x += inset / 2f; } + right.localScale = scale; + right.localPosition = pos; + } + + // Shrink top segment upward and widen to cover gaps left by side insets + if (top != null) + { + Vector3 scale = top.localScale; + Vector3 pos = top.localPosition; + scale.y -= inset; + pos.y += inset / 2f; + if (isVertical) scale.z += 2f * inset; + else scale.x += 2f * inset; + top.localScale = scale; + top.localPosition = pos; + } + } + + private void CreateDoorFrame( + string name, Vector3 wallCenter, float wallHeight, + WallOpening opening, bool isVertical, float frameWidth, + float trimDepth, Color color, Material? material, GameObject container) + { + float doorWidth = opening.Width; + float doorHeight = opening.Height; + + float jambYOffset = -(wallHeight - doorHeight) / 2f; + float sideOffset = doorWidth / 2f + frameWidth / 2f; + + // Jamb sizes + Vector3 jambSize = isVertical + ? new Vector3(trimDepth, doorHeight, frameWidth) + : new Vector3(frameWidth, doorHeight, trimDepth); + + // Left jamb + Vector3 leftJambPos = wallCenter + (isVertical + ? new Vector3(0f, jambYOffset, -sideOffset) + : new Vector3(-sideOffset, jambYOffset, 0f)); + GameObject leftJamb = PrimitiveBuilder.CreateBox($"{name}_Left", leftJambPos, jambSize, color, container.transform); + + // Right jamb + Vector3 rightJambPos = wallCenter + (isVertical + ? new Vector3(0f, jambYOffset, sideOffset) + : new Vector3(sideOffset, jambYOffset, 0f)); + GameObject rightJamb = PrimitiveBuilder.CreateBox($"{name}_Right", rightJambPos, jambSize, color, container.transform); + + // Header (spans across both jambs) + float headerWidth = doorWidth + 2f * frameWidth; + float headerYOffset = -(wallHeight / 2f) + doorHeight + frameWidth / 2f; + Vector3 headerSize = isVertical + ? new Vector3(trimDepth, frameWidth, headerWidth) + : new Vector3(headerWidth, frameWidth, trimDepth); + GameObject header = PrimitiveBuilder.CreateBox($"{name}_Top", + wallCenter + new Vector3(0f, headerYOffset, 0f), headerSize, color, container.transform); + + if (material != null) + { + ApplyMaterial(leftJamb, material); + ApplyMaterial(rightJamb, material); + ApplyMaterial(header, material); + } + } + private static void ApplyMaterial(GameObject obj, Material material) { Renderer r = obj.GetComponent(); From 23ca7a65c3f956c1d13dae0b5a18aa9a55595269 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 23 Feb 2026 07:32:20 -0500 Subject: [PATCH 11/64] feat(Building): add support for windows alongside door openings --- Building/BuildingBuilder.cs | 12 +- Building/Structural/WallBuilder.cs | 263 +++++++++++++++++++++++------ 2 files changed, 218 insertions(+), 57 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index ca1deda..73edd64 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -165,6 +165,8 @@ public BuildingBuilder AddCeiling(Color? color = null, Material? material = null /// Add door on south wall /// Add window on east wall /// Add window on west wall + /// Add windows alongside north door (requires northDoor) + /// Add windows alongside south door (requires southDoor) /// Optional wall color override /// Optional wall material override /// This builder for chaining @@ -173,6 +175,8 @@ public BuildingBuilder AddWalls( bool southDoor = false, bool eastWindow = false, bool westWindow = false, + bool northDoorWindows = false, + bool southDoorWindows = false, Color? color = null, Material? material = null) { @@ -184,8 +188,12 @@ public BuildingBuilder AddWalls( if (material != null) palette.WallMaterial = material; } - _northOpening = northDoor ? WallOpening.Door() : null; - _southOpening = southDoor ? WallOpening.Door() : null; + _northOpening = northDoor + ? (northDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) + : null; + _southOpening = southDoor + ? (southDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) + : null; _eastOpening = eastWindow ? WallOpening.Window() : null; _westOpening = westWindow ? WallOpening.Window() : null; diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index 8754f80..7c00e1b 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -59,6 +59,10 @@ public sealed class WallOpening public float Height { get; set; } = 2.2f; /// The bottom offset (sill height) in meters. Used for windows. public float BottomOffset { get; set; } = 0f; + /// Optional window for the left side segment of a door wall. + public WallOpening? LeftWindow { get; set; } + /// Optional window for the right side segment of a door wall. + public WallOpening? RightWindow { get; set; } /// /// Creates a door opening configuration. @@ -88,6 +92,37 @@ public sealed class WallOpening Height = height, BottomOffset = sillHeight }; + + /// + /// Creates a door opening with windows on the left and/or right side segments. + /// + /// The door width in meters (default 2.0). + /// The door height in meters (default 2.2). + /// Window config for left side, or null for no window. + /// Window config for right side, or null for no window. + /// A new WallOpening configured as a door with side windows. + public static WallOpening DoorWithWindows( + float doorWidth = 2.0f, float doorHeight = 2.2f, + WallOpening? leftWindow = null, WallOpening? rightWindow = null) + { + // When neither window is specified, use defaults on both sides + if (leftWindow == null && rightWindow == null) + { + WallOpening defaultWindow = Window(width: 1.5f, height: 1.5f, sillHeight: 0.8f); + leftWindow = defaultWindow; + rightWindow = defaultWindow; + } + + return new WallOpening + { + Type = WallOpeningType.Door, + Width = doorWidth, + Height = doorHeight, + BottomOffset = 0f, + LeftWindow = leftWindow, + RightWindow = rightWindow + }; + } } /// @@ -178,6 +213,8 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) return opening.Type switch { + WallOpeningType.Door when opening.LeftWindow != null || opening.RightWindow != null + => CreateWallWithDoorAndWindows(wallName, position, size, opening, isVertical), WallOpeningType.Door => CreateWallWithDoor(wallName, position, size, opening, isVertical), WallOpeningType.Window => CreateWallWithWindow(wallName, position, size, opening, isVertical), _ => CreateSolidWall(wallName, position, size) @@ -226,7 +263,7 @@ private GameObject CreateSolidWall(string name, Vector3 position, Vector3 size) private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical) { GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); - + float wallWidth = isVertical ? wallSize.z : wallSize.x; float wallHeight = wallSize.y; float doorWidth = opening.Width; @@ -237,7 +274,7 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w Vector3 rightOffset = isVertical ? Vector3.forward * (doorWidth / 2f + sideWallWidth / 2f) : Vector3.right * (doorWidth / 2f + sideWallWidth / 2f); // Left segment - Vector3 leftSize = isVertical + Vector3 leftSize = isVertical ? new Vector3(_wallThickness, wallHeight, sideWallWidth) : new Vector3(sideWallWidth, wallHeight, _wallThickness); GameObject left = PrimitiveBuilder.CreateBox($"{name}_Left", wallCenter + leftOffset, leftSize, _palette.WallColor, container.transform); @@ -264,73 +301,189 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w return container; } - private GameObject CreateWallWithWindow(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical) + private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical) { GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); float wallWidth = isVertical ? wallSize.z : wallSize.x; float wallHeight = wallSize.y; - float windowWidth = Mathf.Min(opening.Width, wallWidth - 0.5f); - float windowHeight = Mathf.Min(opening.Height, wallHeight - 1.2f); - float windowBottom = opening.BottomOffset; - - float topHeight = wallHeight - (windowBottom + windowHeight); - float sideWidth = (wallWidth - windowWidth) / 2f; - float windowCenterY = (windowBottom + windowHeight / 2f) - (wallHeight / 2f); - - // Bottom segment (sill) - Vector3 bottomSize = isVertical - ? new Vector3(_wallThickness, windowBottom, wallWidth) - : new Vector3(wallWidth, windowBottom, _wallThickness); - Vector3 bottomOffset = Vector3.down * ((wallHeight / 2f) - (windowBottom / 2f)); - GameObject bottom = PrimitiveBuilder.CreateBox($"{name}_Bottom", wallCenter + bottomOffset, bottomSize, _palette.WallColor, container.transform); - ApplyWallMaterial(bottom); - - // Top segment (header) - Vector3 topSize = isVertical - ? new Vector3(_wallThickness, topHeight, wallWidth) - : new Vector3(wallWidth, topHeight, _wallThickness); - float topCenterY = (wallHeight / 2f) - (topHeight / 2f); - Vector3 topOffset = Vector3.up * topCenterY; - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + topOffset, topSize, _palette.WallColor, container.transform); - ApplyWallMaterial(top); - - // Side segments - Vector3 sideSize = isVertical - ? new Vector3(_wallThickness, windowHeight, sideWidth) - : new Vector3(sideWidth, windowHeight, _wallThickness); - - Vector3 leftOffset = isVertical - ? new Vector3(0f, windowCenterY, (windowWidth / 2f + sideWidth / 2f)) - : new Vector3(-(windowWidth / 2f + sideWidth / 2f), windowCenterY, 0f); - GameObject leftSide = PrimitiveBuilder.CreateBox($"{name}_Left", wallCenter + leftOffset, sideSize, _palette.WallColor, container.transform); - ApplyWallMaterial(leftSide); - - Vector3 rightOffset = isVertical - ? new Vector3(0f, windowCenterY, -(windowWidth / 2f + sideWidth / 2f)) - : new Vector3((windowWidth / 2f + sideWidth / 2f), windowCenterY, 0f); - GameObject rightSide = PrimitiveBuilder.CreateBox($"{name}_Right", wallCenter + rightOffset, sideSize, _palette.WallColor, container.transform); - ApplyWallMaterial(rightSide); - - // Window frame - CreateWindowFrame(container.transform, wallCenter, windowWidth, windowHeight, windowCenterY, isVertical); - - // Glass pane + float doorWidth = opening.Width; + float doorHeight = opening.Height; + float fullSideWidth = (wallWidth - doorWidth) / 2f; + + // Left side (may contain a window) + BuildDoorSideSegment(name, "_Left", wallCenter, doorWidth, wallHeight, + fullSideWidth, opening.LeftWindow, isVertical, true, container.transform); + + // Right side (may contain a window) + BuildDoorSideSegment(name, "_Right", wallCenter, doorWidth, wallHeight, + fullSideWidth, opening.RightWindow, isVertical, false, container.transform); + + // Top segment (wall above door) — same as CreateWallWithDoor + float topHeight = wallHeight - doorHeight; + if (topHeight > 0f) + { + Vector3 topSize = isVertical + ? new Vector3(_wallThickness, topHeight, doorWidth) + : new Vector3(doorWidth, topHeight, _wallThickness); + float topCenterY = wallHeight / 2f - topHeight / 2f; + Vector3 topOffset = Vector3.up * topCenterY; + GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + topOffset, topSize, _palette.WallColor, container.transform); + ApplyWallMaterial(top); + } + + return container; + } + + private void BuildDoorSideSegment( + string wallName, string suffix, Vector3 wallCenter, + float doorWidth, float wallHeight, float fullSideWidth, + WallOpening? sideWindow, bool isVertical, bool isLeftSide, Transform parent) + { + float dirSign = isLeftSide ? -1f : 1f; + + if (sideWindow == null || fullSideWidth < 1.0f) + { + // No window — create single solid segment (same as CreateWallWithDoor) + Vector3 offset = isVertical + ? new Vector3(0f, 0f, dirSign * (doorWidth / 2f + fullSideWidth / 2f)) + : new Vector3(dirSign * (doorWidth / 2f + fullSideWidth / 2f), 0f, 0f); + Vector3 size = isVertical + ? new Vector3(_wallThickness, wallHeight, fullSideWidth) + : new Vector3(fullSideWidth, wallHeight, _wallThickness); + GameObject solid = PrimitiveBuilder.CreateBox($"{wallName}{suffix}", wallCenter + offset, size, _palette.WallColor, parent); + ApplyWallMaterial(solid); + return; + } + + // With window: small strip adjacent to the door (for InsetDoorWallSegments), window section gets the rest + float stripWidth = Mathf.Min(0.5f, fullSideWidth - sideWindow.Width - 0.4f); + stripWidth = Mathf.Max(0.3f, stripWidth); + float windowSectionWidth = fullSideWidth - stripWidth; + + // Solid strip next to the door (keeps {wallName}_Left / _Right name for InsetDoorWallSegments) + float stripCenterOffset = doorWidth / 2f + stripWidth / 2f; + Vector3 stripOffset = isVertical + ? new Vector3(0f, 0f, dirSign * stripCenterOffset) + : new Vector3(dirSign * stripCenterOffset, 0f, 0f); + Vector3 stripSize = isVertical + ? new Vector3(_wallThickness, wallHeight, stripWidth) + : new Vector3(stripWidth, wallHeight, _wallThickness); + GameObject strip = PrimitiveBuilder.CreateBox($"{wallName}{suffix}", wallCenter + stripOffset, stripSize, _palette.WallColor, parent); + ApplyWallMaterial(strip); + + // Window section in the remaining area (no overlap with strip so InsetDoorWallSegments works) + float winSectionCenterOffset = doorWidth / 2f + stripWidth + windowSectionWidth / 2f; + Vector3 winSectionCenter = wallCenter + (isVertical + ? new Vector3(0f, 0f, dirSign * winSectionCenterOffset) + : new Vector3(dirSign * winSectionCenterOffset, 0f, 0f)); + + // Shift window toward door by stripWidth/2 so it centers in the full side width + float windowOffset = -dirSign * stripWidth / 2f; + CreateWindowInSection($"{wallName}{suffix}Win", winSectionCenter, windowSectionWidth, wallHeight, + sideWindow, isVertical, parent, windowOffset); + } + + private GameObject CreateWallWithWindow(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical) + { + GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); + float wallWidth = isVertical ? wallSize.z : wallSize.x; + CreateWindowInSection(name, wallCenter, wallWidth, wallSize.y, opening, isVertical, container.transform); + return container; + } + + private void CreateWindowInSection( + string namePrefix, Vector3 sectionCenter, + float sectionWidth, float sectionHeight, + WallOpening window, bool isVertical, Transform parent, + float windowOffset = 0f) + { + float windowWidth = Mathf.Min(window.Width, sectionWidth - 0.5f); + float windowHeight = Mathf.Min(window.Height, sectionHeight - 1.2f); + float windowBottom = window.BottomOffset; + + float topHeight = sectionHeight - (windowBottom + windowHeight); + float sideWidth = (sectionWidth - windowWidth) / 2f; + float halfHeight = sectionHeight / 2f; + float windowCenterY = (windowBottom + windowHeight / 2f) - halfHeight; + + // Window center shifted along wall axis (positive = +Z for vertical, +X for horizontal) + Vector3 winShift = isVertical + ? new Vector3(0f, 0f, windowOffset) + : new Vector3(windowOffset, 0f, 0f); + Vector3 windowCenter = sectionCenter + winShift; + + // Bottom segment (sill) — full section width, no shift + if (windowBottom > 0.01f) + { + Vector3 bottomSize = isVertical + ? new Vector3(_wallThickness, windowBottom, sectionWidth) + : new Vector3(sectionWidth, windowBottom, _wallThickness); + Vector3 bottomOffset = Vector3.down * (halfHeight - windowBottom / 2f); + GameObject bottom = PrimitiveBuilder.CreateBox($"{namePrefix}_Bottom", sectionCenter + bottomOffset, bottomSize, _palette.WallColor, parent); + ApplyWallMaterial(bottom); + } + + // Top segment (header) — full section width, no shift + if (topHeight > 0.01f) + { + Vector3 topSize = isVertical + ? new Vector3(_wallThickness, topHeight, sectionWidth) + : new Vector3(sectionWidth, topHeight, _wallThickness); + Vector3 topOffset = Vector3.up * (halfHeight - topHeight / 2f); + GameObject top = PrimitiveBuilder.CreateBox($"{namePrefix}_Top", sectionCenter + topOffset, topSize, _palette.WallColor, parent); + ApplyWallMaterial(top); + } + + // Side segments — asymmetric widths when window is offset + // For isVertical: +Z side = sideWidth - offset, -Z side = sideWidth + offset + // For non-vertical: -X side = sideWidth + offset, +X side = sideWidth - offset + float posSideWidth = sideWidth - windowOffset; + float negSideWidth = sideWidth + windowOffset; + float leftSideWidth = isVertical ? posSideWidth : negSideWidth; + float rightSideWidth = isVertical ? negSideWidth : posSideWidth; + + if (leftSideWidth > 0.01f) + { + Vector3 leftSize = isVertical + ? new Vector3(_wallThickness, windowHeight, leftSideWidth) + : new Vector3(leftSideWidth, windowHeight, _wallThickness); + Vector3 leftOffset = isVertical + ? new Vector3(0f, windowCenterY, windowWidth / 2f + leftSideWidth / 2f) + : new Vector3(-(windowWidth / 2f + leftSideWidth / 2f), windowCenterY, 0f); + GameObject leftSide = PrimitiveBuilder.CreateBox($"{namePrefix}_Left", windowCenter + leftOffset, leftSize, _palette.WallColor, parent); + ApplyWallMaterial(leftSide); + } + + if (rightSideWidth > 0.01f) + { + Vector3 rightSize = isVertical + ? new Vector3(_wallThickness, windowHeight, rightSideWidth) + : new Vector3(rightSideWidth, windowHeight, _wallThickness); + Vector3 rightOffset = isVertical + ? new Vector3(0f, windowCenterY, -(windowWidth / 2f + rightSideWidth / 2f)) + : new Vector3(windowWidth / 2f + rightSideWidth / 2f, windowCenterY, 0f); + GameObject rightSide = PrimitiveBuilder.CreateBox($"{namePrefix}_Right", windowCenter + rightOffset, rightSize, _palette.WallColor, parent); + ApplyWallMaterial(rightSide); + } + + // Window frame — at shifted center + CreateWindowFrame(parent, windowCenter, windowWidth, windowHeight, windowCenterY, isVertical); + + // Glass pane — at shifted center Vector3 glassSize = isVertical ? new Vector3(_wallThickness * 0.2f, windowHeight - 0.1f, windowWidth - 0.1f) : new Vector3(windowWidth - 0.1f, windowHeight - 0.1f, _wallThickness * 0.2f); - Vector3 glassOffset = new Vector3(0f, windowCenterY, 0f); - GameObject glass = PrimitiveBuilder.CreateBox($"{name}_WindowGlass", wallCenter + glassOffset, glassSize, new Color(0.7f, 0.9f, 1f), container.transform); - - // Apply glass material + GameObject glass = PrimitiveBuilder.CreateBox($"{namePrefix}_WindowGlass", + windowCenter + new Vector3(0f, windowCenterY, 0f), glassSize, + new Color(0.7f, 0.9f, 1f), parent); + Material glassMat = Materials.LaundromatGlass; if (glassMat != null) { Renderer r = glass.GetComponent(); if (r != null) r.material = glassMat; } - - return container; } private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windowWidth, float windowHeight, float windowCenterY, bool isVertical) From fc6ef6b0ac2a6eb885e7b49b365466bf0919d59f Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 23 Feb 2026 07:44:15 -0500 Subject: [PATCH 12/64] feat(Building): make AddWalls boolean overload support doors and windows on all sides --- Building/BuildingBuilder.cs | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 73edd64..dacd745 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -18,7 +18,7 @@ namespace S1MAPI.Building /// .WithConfig(BuildingConfig.Dispensary) /// .AddFloor() /// .AddCeiling() - /// .AddWalls(southDoor: true, eastWindow: true, westWindow: true) + /// .AddWalls(southDoor: true, eastDoor: true, westWindow: true) /// .AddLights() /// .AddFurniture(FurnitureType.Counter, "north") /// .Build(); @@ -163,20 +163,26 @@ public BuildingBuilder AddCeiling(Color? color = null, Material? material = null /// /// Add door on north wall /// Add door on south wall + /// Add door on east wall + /// Add door on west wall + /// Add window on north wall + /// Add window on south wall /// Add window on east wall /// Add window on west wall /// Add windows alongside north door (requires northDoor) /// Add windows alongside south door (requires southDoor) + /// Add windows alongside east door (requires eastDoor) + /// Add windows alongside west door (requires westDoor) /// Optional wall color override /// Optional wall material override /// This builder for chaining public BuildingBuilder AddWalls( - bool northDoor = false, - bool southDoor = false, - bool eastWindow = false, - bool westWindow = false, - bool northDoorWindows = false, - bool southDoorWindows = false, + bool northDoor = false, bool southDoor = false, + bool eastDoor = false, bool westDoor = false, + bool northWindow = false, bool southWindow = false, + bool eastWindow = false, bool westWindow = false, + bool northDoorWindows = false, bool southDoorWindows = false, + bool eastDoorWindows = false, bool westDoorWindows = false, Color? color = null, Material? material = null) { @@ -190,12 +196,16 @@ public BuildingBuilder AddWalls( _northOpening = northDoor ? (northDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) - : null; + : (northWindow ? WallOpening.Window() : null); _southOpening = southDoor ? (southDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) - : null; - _eastOpening = eastWindow ? WallOpening.Window() : null; - _westOpening = westWindow ? WallOpening.Window() : null; + : (southWindow ? WallOpening.Window() : null); + _eastOpening = eastDoor + ? (eastDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) + : (eastWindow ? WallOpening.Window() : null); + _westOpening = westDoor + ? (westDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) + : (westWindow ? WallOpening.Window() : null); var builder = GetWallBuilder(palette); builder.BuildWalls( From 9eabccf91c528bc7610ec84b0bb5771ef80fab97 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 23 Feb 2026 10:11:34 -0500 Subject: [PATCH 13/64] feat(Building): support multiple windows per wall and fix glass material --- Building/Structural/WallBuilder.cs | 157 +++++++++++++++++++++++------ Utils/Constants.cs | 71 +++++++++++++ 2 files changed, 196 insertions(+), 32 deletions(-) diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index 7c00e1b..65e144f 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -1,5 +1,6 @@ using S1MAPI.Building.Config; using S1MAPI.ProceduralMesh; +using S1MAPI.Utils; using UnityEngine; using S1MAPI.S1; @@ -63,6 +64,12 @@ public sealed class WallOpening public WallOpening? LeftWindow { get; set; } /// Optional window for the right side segment of a door wall. public WallOpening? RightWindow { get; set; } + /// Number of window panes across the opening (default 1). + public int Count { get; set; } = 1; + /// Width of the wall divider between adjacent panes in meters. + public float DividerWidth { get; set; } = Constants.Window.DefaultDividerWidth; + /// Optional glass material override. When null, uses Materials.WindowGlass. + public Material? GlassMaterial { get; set; } /// /// Creates a door opening configuration. @@ -84,13 +91,22 @@ public sealed class WallOpening /// The window width in meters (default 2.5). /// The window height in meters (default 2.0). /// The sill height from the floor in meters (default 0.8). + /// Number of window panes to distribute across the opening width (default 1). + /// Width of wall dividers between adjacent panes in meters (default 0.15). + /// Optional glass material override. Defaults to Materials.WindowGlass when null. /// A new WallOpening configured as a window. - public static WallOpening Window(float width = 2.5f, float height = 2.0f, float sillHeight = 0.8f) => new() + public static WallOpening Window( + float width = 2.5f, float height = 2.0f, float sillHeight = 0.8f, + int count = 1, float dividerWidth = Constants.Window.DefaultDividerWidth, + Material? glassMaterial = null) => new() { Type = WallOpeningType.Window, Width = width, Height = height, - BottomOffset = sillHeight + BottomOffset = sillHeight, + Count = count, + DividerWidth = dividerWidth, + GlassMaterial = glassMaterial }; /// @@ -133,6 +149,9 @@ public sealed class WallBuilder { #region Fields + private static readonly Color FrameColor = new Color(0.1f, 0.1f, 0.1f); + private static readonly Color GlassTint = new Color(0.7f, 0.9f, 1f); + private readonly Transform _parent; private readonly Vector3 _roomSize; private readonly float _wallThickness; @@ -342,7 +361,7 @@ private void BuildDoorSideSegment( { float dirSign = isLeftSide ? -1f : 1f; - if (sideWindow == null || fullSideWidth < 1.0f) + if (sideWindow == null || fullSideWidth < Constants.Window.MinDoorSideWidth) { // No window — create single solid segment (same as CreateWallWithDoor) Vector3 offset = isVertical @@ -357,8 +376,8 @@ private void BuildDoorSideSegment( } // With window: small strip adjacent to the door (for InsetDoorWallSegments), window section gets the rest - float stripWidth = Mathf.Min(0.5f, fullSideWidth - sideWindow.Width - 0.4f); - stripWidth = Mathf.Max(0.3f, stripWidth); + float stripWidth = Mathf.Min(Constants.Window.MaxDoorStripWidth, fullSideWidth - sideWindow.Width - Constants.Window.DoorStripMargin); + stripWidth = Mathf.Max(Constants.Window.MinDoorStripWidth, stripWidth); float windowSectionWidth = fullSideWidth - stripWidth; // Solid strip next to the door (keeps {wallName}_Left / _Right name for InsetDoorWallSegments) @@ -398,8 +417,42 @@ private void CreateWindowInSection( WallOpening window, bool isVertical, Transform parent, float windowOffset = 0f) { - float windowWidth = Mathf.Min(window.Width, sectionWidth - 0.5f); - float windowHeight = Mathf.Min(window.Height, sectionHeight - 1.2f); + int paneCount = Mathf.Max(1, window.Count); + float windowWidth; + if (paneCount > 1) + { + // Multi-pane: Width is per-pane width (capped), equal gaps for sides and dividers + float perPane = Mathf.Min(window.Width, Mathf.Min(sectionWidth - Constants.Window.SideMargin, Constants.Window.MaxPaneWidth)); + float equalGap = (sectionWidth - paneCount * perPane) / (paneCount + 1); + + // Enforce minimum gap; shrink panes if needed + if (equalGap < Constants.Window.MinGap) + { + equalGap = Constants.Window.MinGap; + perPane = (sectionWidth - (paneCount + 1) * equalGap) / paneCount; + } + + // Auto-reduce count if panes would be too narrow + while (perPane < Constants.Window.MinPaneWidth && paneCount > 1) + { + paneCount--; + equalGap = (sectionWidth - paneCount * perPane) / (paneCount + 1); + if (equalGap < Constants.Window.MinGap) + { + equalGap = Constants.Window.MinGap; + perPane = (sectionWidth - (paneCount + 1) * equalGap) / paneCount; + } + } + + // Band spans all panes + inner gaps; outer gaps become sideWidth + windowWidth = paneCount * perPane + (paneCount - 1) * equalGap; + } + else + { + windowWidth = Mathf.Min(window.Width, sectionWidth - Constants.Window.SideMargin); + } + + float windowHeight = Mathf.Min(window.Height, sectionHeight - Constants.Window.VerticalMargin); float windowBottom = window.BottomOffset; float topHeight = sectionHeight - (windowBottom + windowHeight); @@ -414,7 +467,7 @@ private void CreateWindowInSection( Vector3 windowCenter = sectionCenter + winShift; // Bottom segment (sill) — full section width, no shift - if (windowBottom > 0.01f) + if (windowBottom > Constants.Window.SegmentThreshold) { Vector3 bottomSize = isVertical ? new Vector3(_wallThickness, windowBottom, sectionWidth) @@ -425,7 +478,7 @@ private void CreateWindowInSection( } // Top segment (header) — full section width, no shift - if (topHeight > 0.01f) + if (topHeight > Constants.Window.SegmentThreshold) { Vector3 topSize = isVertical ? new Vector3(_wallThickness, topHeight, sectionWidth) @@ -443,7 +496,7 @@ private void CreateWindowInSection( float leftSideWidth = isVertical ? posSideWidth : negSideWidth; float rightSideWidth = isVertical ? negSideWidth : posSideWidth; - if (leftSideWidth > 0.01f) + if (leftSideWidth > Constants.Window.SegmentThreshold) { Vector3 leftSize = isVertical ? new Vector3(_wallThickness, windowHeight, leftSideWidth) @@ -455,7 +508,7 @@ private void CreateWindowInSection( ApplyWallMaterial(leftSide); } - if (rightSideWidth > 0.01f) + if (rightSideWidth > Constants.Window.SegmentThreshold) { Vector3 rightSize = isVertical ? new Vector3(_wallThickness, windowHeight, rightSideWidth) @@ -467,39 +520,79 @@ private void CreateWindowInSection( ApplyWallMaterial(rightSide); } - // Window frame — at shifted center - CreateWindowFrame(parent, windowCenter, windowWidth, windowHeight, windowCenterY, isVertical); + // Multi-pane window rendering + // For count > 1: divider width == sideWidth (equal gaps by construction) + float dividerW = paneCount > 1 ? sideWidth : window.DividerWidth; + float paneWidth = paneCount > 1 + ? (windowWidth - (paneCount - 1) * dividerW) / paneCount + : windowWidth; - // Glass pane — at shifted center - Vector3 glassSize = isVertical - ? new Vector3(_wallThickness * 0.2f, windowHeight - 0.1f, windowWidth - 0.1f) - : new Vector3(windowWidth - 0.1f, windowHeight - 0.1f, _wallThickness * 0.2f); - GameObject glass = PrimitiveBuilder.CreateBox($"{namePrefix}_WindowGlass", - windowCenter + new Vector3(0f, windowCenterY, 0f), glassSize, - new Color(0.7f, 0.9f, 1f), parent); + float bandStart = -windowWidth / 2f + paneWidth / 2f; - Material glassMat = Materials.LaundromatGlass; - if (glassMat != null) + for (int i = 0; i < paneCount; i++) { - Renderer r = glass.GetComponent(); - if (r != null) r.material = glassMat; + float paneOffset = bandStart + i * (paneWidth + dividerW); + + Vector3 paneShift = isVertical + ? new Vector3(0f, 0f, paneOffset) + : new Vector3(paneOffset, 0f, 0f); + Vector3 paneCenter = windowCenter + paneShift; + + // Frame for this pane + string framePrefix = paneCount > 1 ? $"Frame{i}_" : "Frame"; + CreateWindowFrame(parent, paneCenter, paneWidth, windowHeight, windowCenterY, isVertical, framePrefix); + + // Glass pane + Vector3 glassSize = isVertical + ? new Vector3(_wallThickness * 0.2f, windowHeight - 0.1f, paneWidth - 0.1f) + : new Vector3(paneWidth - 0.1f, windowHeight - 0.1f, _wallThickness * 0.2f); + + string paneSuffix = paneCount > 1 ? $"_{i}" : ""; + GameObject glass = PrimitiveBuilder.CreateBox( + $"{namePrefix}_WindowGlass{paneSuffix}", + paneCenter + new Vector3(0f, windowCenterY, 0f), + glassSize, GlassTint, parent); + + Material glassMat = window.GlassMaterial ?? Materials.WindowGlass; + if (glassMat != null) + { + Renderer r = glass.GetComponent(); + if (r != null) r.material = glassMat; + } + + // Divider wall between this pane and the next + if (i < paneCount - 1) + { + float dividerOffset = paneOffset + paneWidth / 2f + dividerW / 2f; + Vector3 dividerShift = isVertical + ? new Vector3(0f, 0f, dividerOffset) + : new Vector3(dividerOffset, 0f, 0f); + Vector3 dividerSize = isVertical + ? new Vector3(_wallThickness, windowHeight, dividerW) + : new Vector3(dividerW, windowHeight, _wallThickness); + + GameObject divider = PrimitiveBuilder.CreateBox( + $"{namePrefix}_Divider_{i}", + windowCenter + dividerShift + new Vector3(0f, windowCenterY, 0f), + dividerSize, _palette.WallColor, parent); + ApplyWallMaterial(divider); + } } } - private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windowWidth, float windowHeight, float windowCenterY, bool isVertical) + private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windowWidth, float windowHeight, float windowCenterY, bool isVertical, string namePrefix = "Frame") { - Color frameColor = new Color(0.1f, 0.1f, 0.1f); - float frameDepth = 0.05f; - float frameWidth = 0.1f; + float frameDepth = Constants.Window.FrameDepth; + float frameWidth = Constants.Window.FrameWidth; // Top frame Vector3 topFrameSize = isVertical ? new Vector3(_wallThickness + frameDepth, frameWidth, windowWidth) : new Vector3(windowWidth, frameWidth, _wallThickness + frameDepth); - PrimitiveBuilder.CreateBox("FrameTop", wallCenter + new Vector3(0f, windowCenterY + windowHeight / 2f - frameWidth / 2f, 0f), topFrameSize, frameColor, parent); + PrimitiveBuilder.CreateBox($"{namePrefix}Top", wallCenter + new Vector3(0f, windowCenterY + windowHeight / 2f - frameWidth / 2f, 0f), topFrameSize, FrameColor, parent); // Bottom frame - PrimitiveBuilder.CreateBox("FrameBottom", wallCenter + new Vector3(0f, windowCenterY - windowHeight / 2f + frameWidth / 2f, 0f), topFrameSize, frameColor, parent); + PrimitiveBuilder.CreateBox($"{namePrefix}Bottom", wallCenter + new Vector3(0f, windowCenterY - windowHeight / 2f + frameWidth / 2f, 0f), topFrameSize, FrameColor, parent); // Side frames Vector3 sideFrameSize = isVertical @@ -510,8 +603,8 @@ private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windo Vector3 leftFrameOffset = isVertical ? new Vector3(0f, windowCenterY, sideOffset) : new Vector3(-sideOffset, windowCenterY, 0f); Vector3 rightFrameOffset = isVertical ? new Vector3(0f, windowCenterY, -sideOffset) : new Vector3(sideOffset, windowCenterY, 0f); - PrimitiveBuilder.CreateBox("FrameLeft", wallCenter + leftFrameOffset, sideFrameSize, frameColor, parent); - PrimitiveBuilder.CreateBox("FrameRight", wallCenter + rightFrameOffset, sideFrameSize, frameColor, parent); + PrimitiveBuilder.CreateBox($"{namePrefix}Left", wallCenter + leftFrameOffset, sideFrameSize, FrameColor, parent); + PrimitiveBuilder.CreateBox($"{namePrefix}Right", wallCenter + rightFrameOffset, sideFrameSize, FrameColor, parent); } private void ApplyWallMaterial(GameObject wall) diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 98bbfbc..c91a2a9 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -141,6 +141,77 @@ public static class Spatial public const float DefaultStepDepth = 0.3f; } + /// + /// Window geometry and rendering constants. + /// + public static class Window + { + /// + /// Default divider width between adjacent window panes in meters. + /// + public const float DefaultDividerWidth = 0.15f; + + /// + /// Maximum individual pane width for multi-pane windows in meters. + /// + public const float MaxPaneWidth = 2.0f; + + /// + /// Minimum individual pane width in meters. Pane count is auto-reduced if panes would be narrower. + /// + public const float MinPaneWidth = 0.3f; + + /// + /// Minimum gap between window panes and wall edges in meters. + /// + public const float MinGap = 0.15f; + + /// + /// Minimum horizontal margin reserved for side walls in a window section. + /// + public const float SideMargin = 0.5f; + + /// + /// Minimum vertical margin reserved for header and sill in a window section. + /// + public const float VerticalMargin = 1.2f; + + /// + /// Minimum side width required to place a window in a door side segment. + /// + public const float MinDoorSideWidth = 1.0f; + + /// + /// Window frame depth in meters. + /// + public const float FrameDepth = 0.05f; + + /// + /// Window frame member width in meters. + /// + public const float FrameWidth = 0.1f; + + /// + /// Geometry threshold below which wall segments are not created. + /// + public const float SegmentThreshold = 0.01f; + + /// + /// Maximum width of the solid strip next to a door in door-with-windows walls. + /// + public const float MaxDoorStripWidth = 0.5f; + + /// + /// Minimum width of the solid strip next to a door in door-with-windows walls. + /// + public const float MinDoorStripWidth = 0.3f; + + /// + /// Margin subtracted from the available side width when sizing the door strip. + /// + public const float DoorStripMargin = 0.4f; + } + /// /// Terrain and area clearing constants. /// From dbac6e581d7439516daf3efb065de6128d215541 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 23 Feb 2026 13:03:32 -0500 Subject: [PATCH 14/64] feat(Building): add RoofBuilder with parapet and hip roof styles --- Building/BuildingBuilder.cs | 73 ++++ Building/Components/LightingBuilder.cs | 2 +- Building/Structural/DecorBuilder.cs | 4 +- Building/Structural/RoofBuilder.cs | 474 +++++++++++++++++++++++++ Utils/Constants.cs | 68 ++++ 5 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 Building/Structural/RoofBuilder.cs diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index dacd745..e10cac6 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -37,6 +37,7 @@ public sealed class BuildingBuilder private FurnitureBuilder? _furnitureBuilder; private LightingBuilder? _lightingBuilder; private DecorBuilder? _decorBuilder; + private RoofBuilder? _roofBuilder; private PrefabPlacer? _prefabPlacer; // Stored wall openings for cross-builder communication (e.g., base molding gap) @@ -246,6 +247,8 @@ public BuildingBuilder AddWalls( /// /// Add decorative trim around the roofline. + /// Not needed when using or , + /// which include their own roof structure. Useful for custom roof designs with . /// /// Trim height in meters /// Optional material override @@ -258,6 +261,8 @@ public BuildingBuilder AddRoofTrim(float height = 0.3f, Material? material = nul /// /// Add a secondary decorative trim above the roofline. + /// Not needed when using or , + /// which include their own roof structure. Useful for custom roof designs with . /// /// Trim height in meters /// Optional material override @@ -268,6 +273,68 @@ public BuildingBuilder AddSecondaryRoofTrim(float height = 0.15f, Material? mate return this; } + /// + /// Add a parapet roof (raised wall and cap above the roofline). + /// The cap extends past the parapet wall by the overhang amount, creating a ledge. + /// Includes a thin roof slab at ceiling height. For interior ceilings, use + /// separately — the ceiling sits just below the roof slab with no overlap. + /// Use for a prominent commercial look or + /// for a subtler profile. + /// + /// Sizing preset (Deep or Shallow). Overridden by explicit dimensions. + /// Height of the parapet wall in meters. Null uses preset default. + /// Depth of the parapet wall. Null uses wall thickness + padding. + /// Height of the cap. Null uses preset default. + /// How far the cap extends past the parapet wall on each side. Null uses preset default. + /// Color override for the parapet wall. + /// Material override for the parapet wall. + /// Color override for the cap. + /// Material override for the cap. + /// This builder for chaining + public BuildingBuilder AddParapetRoof( + ParapetPreset preset = ParapetPreset.Deep, + float? parapetHeight = null, + float? parapetDepth = null, + float? capHeight = null, + float? capOverhang = null, + Color? parapetColor = null, + Material? parapetMaterial = null, + Color? capColor = null, + Material? capMaterial = null) + { + GetRoofBuilder().AddParapetRoof(preset, parapetHeight, parapetDepth, + capHeight, capOverhang, parapetColor, parapetMaterial, capColor, capMaterial); + return this; + } + + /// + /// Add a hip (four-slope) roof using custom mesh geometry. + /// All four sides slope inward to a central ridge that is shorter than the building length. + /// For square buildings, the ridge collapses to a point (pyramid roof). + /// A base slab sits at ceiling height giving the roof visible thickness from below. + /// For interior ceilings, use separately — the ceiling sits + /// just below the roof slab with no overlap. + /// + /// Height of the ridge peak above the ceiling in meters. + /// How far the roof eaves extend past the walls in meters. + /// If true, ridge runs along X axis. If false, along Z. Null auto-selects the longer axis. + /// Color for the sloped roof planes. + /// Material for the sloped roof planes. Null uses fallback color. + /// Height of the 3D base slab beneath the slopes. 0 disables the slab. + /// This builder for chaining + public BuildingBuilder AddHipRoof( + float ridgeHeight = Constants.Roof.DefaultRidgeHeight, + float overhang = Constants.Roof.DefaultOverhang, + bool? ridgeAlongX = null, + Color? roofColor = null, + Material? roofMaterial = null, + float baseSlabHeight = Constants.Roof.DefaultBaseSlabHeight) + { + GetRoofBuilder().AddHipRoof(ridgeHeight, overhang, ridgeAlongX, + roofColor, roofMaterial, baseSlabHeight); + return this; + } + /// /// Add structural pillars at corners. /// @@ -502,6 +569,7 @@ private void InvalidateBuilders() _furnitureBuilder = null; _lightingBuilder = null; _decorBuilder = null; + _roofBuilder = null; // PrefabPlacer doesn't depend on room size } @@ -530,6 +598,11 @@ private DecorBuilder GetDecorBuilder(BuildingPalette? palette = null) return _decorBuilder ??= new DecorBuilder(_root.transform, _roomSize, palette ?? _config.Palette); } + private RoofBuilder GetRoofBuilder() + { + return _roofBuilder ??= new RoofBuilder(_root.transform, _roomSize, _config.WallThickness, _config.Palette); + } + private PrefabPlacer GetPrefabPlacer() { return _prefabPlacer ??= new PrefabPlacer(_root.transform); diff --git a/Building/Components/LightingBuilder.cs b/Building/Components/LightingBuilder.cs index 959ff70..2889776 100644 --- a/Building/Components/LightingBuilder.cs +++ b/Building/Components/LightingBuilder.cs @@ -58,7 +58,7 @@ public GameObject AddCeilingLights(float? intensity = null, Color? color = null, float xStep = _roomSize.x / (xCount + 1); float zStep = _roomSize.z / (zCount + 1); - float yPos = _roomSize.y - 0.2f; // Just below ceiling + float yPos = _roomSize.y - 0.3f; // Below ceiling (ceiling sits inside room at _roomSize.y - thickness) for (int x = 1; x <= xCount; x++) { diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index e436c77..b789f2d 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -408,13 +408,15 @@ public GameObject AddFloor(float thickness = 0.1f) /// /// Add ceiling to the room. + /// The ceiling's top surface is flush with the top of the walls (_roomSize.y), + /// so roof slabs placed at _roomSize.y sit directly on top with no overlap. /// /// Ceiling thickness in meters /// The ceiling GameObject public GameObject AddCeiling(float thickness = 0.1f) { GameObject ceiling = PrimitiveBuilder.CreateBox("Ceiling", - new Vector3(_roomSize.x / 2f, _roomSize.y + thickness / 2f, _roomSize.z / 2f), + new Vector3(_roomSize.x / 2f, _roomSize.y - thickness / 2f, _roomSize.z / 2f), new Vector3(_roomSize.x, thickness, _roomSize.z), _palette.CeilingColor, _parent); diff --git a/Building/Structural/RoofBuilder.cs b/Building/Structural/RoofBuilder.cs new file mode 100644 index 0000000..c3f7328 --- /dev/null +++ b/Building/Structural/RoofBuilder.cs @@ -0,0 +1,474 @@ +using S1MAPI.Building.Config; +using S1MAPI.Core; +using S1MAPI.ProceduralMesh; +using S1MAPI.Utils; +using UnityEngine; + +namespace S1MAPI.Building.Structural +{ + /// + /// Preset sizing for parapet roofs. + /// + public enum ParapetPreset + { + /// + /// Tall thick parapet wall with prominent cap and overhang. + /// Creates a visible roof recess for a substantial commercial look. + /// + Deep, + + /// + /// Shorter parapet wall with a subtle cap and slight overhang. + /// Cleaner, less imposing appearance. + /// + Shallow + } + + /// + /// Creates roof structures including parapet walls and hip roofs. + /// Extracted from DecorBuilder for SRP compliance. + /// + public sealed class RoofBuilder + { + #region Fields + + private readonly Transform _parent; + private readonly Vector3 _roomSize; + private readonly float _wallThickness; + private readonly BuildingPalette _palette; + + #endregion + + #region Constructor + + /// + /// Create a new roof builder. + /// + /// Parent transform for roof elements + /// Room dimensions + /// Wall thickness in meters + /// Material and color palette + public RoofBuilder(Transform parent, Vector3 roomSize, float wallThickness, BuildingPalette palette) + { + _parent = parent; + _roomSize = roomSize; + _wallThickness = wallThickness; + _palette = palette; + } + + #endregion + + #region Public API + + /// + /// Add a parapet roof (raised wall and cap above the roofline). + /// The cap extends past the parapet wall by the overhang amount, creating a ledge. + /// Includes a thin roof slab at ceiling height. For interior ceilings, use AddCeiling() + /// separately — the ceiling sits just below the roof slab with no overlap. + /// + /// Sizing preset (Deep or Shallow). Overridden by explicit dimensions. + /// Height of the parapet wall in meters. Null uses preset default. + /// Depth of the parapet wall. Null uses wall thickness + padding. + /// Height of the cap on top of the parapet. Null uses preset default. + /// How far the cap extends past the parapet wall on each side. Null uses preset default. + /// Color override for the parapet wall. + /// Material override for the parapet wall. + /// Color override for the cap. + /// Material override for the cap. + /// The roof container GameObject. + public GameObject AddParapetRoof( + ParapetPreset preset = ParapetPreset.Deep, + float? parapetHeight = null, + float? parapetDepth = null, + float? capHeight = null, + float? capOverhang = null, + Color? parapetColor = null, + Material? parapetMaterial = null, + Color? capColor = null, + Material? capMaterial = null) + { + // Resolve preset defaults + float pHeight = parapetHeight ?? (preset == ParapetPreset.Deep + ? Constants.Roof.DeepParapetHeight + : Constants.Roof.ShallowParapetHeight); + + float pDepth = parapetDepth ?? (_wallThickness + Constants.Roof.ParapetDepthPadding); + + float cHeight = capHeight ?? (preset == ParapetPreset.Deep + ? Constants.Roof.DeepCapHeight + : Constants.Roof.ShallowCapHeight); + + float cOverhang = capOverhang ?? (preset == ParapetPreset.Deep + ? Constants.Roof.DeepCapOverhang + : Constants.Roof.ShallowCapOverhang); + + // Cap extends past parapet on exterior side only + float capDepthExt = pDepth + cOverhang; + float capCornerWidth = pDepth + 2f * cOverhang; + float capOffset = cOverhang / 2f; + + GameObject container = BuildingUtilities.CreateFolder("ParapetRoof", _parent); + + // --- Parapet wall (4 strips above ceiling) --- + Color wallColor = parapetColor ?? _palette.TrimColor; + float wallY = _roomSize.y + pHeight / 2f; + + GameObject parapetN = PrimitiveBuilder.CreateBox("Parapet_North", + new Vector3(_roomSize.x / 2f, wallY, _roomSize.z), + new Vector3(_roomSize.x + pDepth, pHeight, pDepth), + wallColor, container.transform); + + GameObject parapetS = PrimitiveBuilder.CreateBox("Parapet_South", + new Vector3(_roomSize.x / 2f, wallY, 0f), + new Vector3(_roomSize.x + pDepth, pHeight, pDepth), + wallColor, container.transform); + + GameObject parapetE = PrimitiveBuilder.CreateBox("Parapet_East", + new Vector3(_roomSize.x, wallY, _roomSize.z / 2f), + new Vector3(pDepth, pHeight, _roomSize.z - pDepth), + wallColor, container.transform); + + GameObject parapetW = PrimitiveBuilder.CreateBox("Parapet_West", + new Vector3(0f, wallY, _roomSize.z / 2f), + new Vector3(pDepth, pHeight, _roomSize.z - pDepth), + wallColor, container.transform); + + Material? wallMat = parapetMaterial ?? _palette.TrimMaterial; + if (wallMat != null) + { + ApplyMaterial(parapetN, wallMat); + ApplyMaterial(parapetS, wallMat); + ApplyMaterial(parapetE, wallMat); + ApplyMaterial(parapetW, wallMat); + } + + // --- Cap (4 strips on top of parapet, overhang on exterior side only) --- + Color cColor = capColor ?? _palette.AccentColor; + float capY = _roomSize.y + pHeight + cHeight / 2f; + + GameObject capN = PrimitiveBuilder.CreateBox("Cap_North", + new Vector3(_roomSize.x / 2f, capY, _roomSize.z + capOffset), + new Vector3(_roomSize.x + capCornerWidth, cHeight, capDepthExt), + cColor, container.transform); + + GameObject capS = PrimitiveBuilder.CreateBox("Cap_South", + new Vector3(_roomSize.x / 2f, capY, -capOffset), + new Vector3(_roomSize.x + capCornerWidth, cHeight, capDepthExt), + cColor, container.transform); + + GameObject capE = PrimitiveBuilder.CreateBox("Cap_East", + new Vector3(_roomSize.x + capOffset, capY, _roomSize.z / 2f), + new Vector3(capDepthExt, cHeight, _roomSize.z - pDepth), + cColor, container.transform); + + GameObject capW = PrimitiveBuilder.CreateBox("Cap_West", + new Vector3(-capOffset, capY, _roomSize.z / 2f), + new Vector3(capDepthExt, cHeight, _roomSize.z - pDepth), + cColor, container.transform); + + Material? cMat = capMaterial ?? _palette.AccentMaterial ?? _palette.TrimMaterial; + if (cMat != null) + { + ApplyMaterial(capN, cMat); + ApplyMaterial(capS, cMat); + ApplyMaterial(capE, cMat); + ApplyMaterial(capW, cMat); + } + + // --- Roof slab (thin slab at ceiling height, bottom sits at _roomSize.y) --- + Material roofSlabMat = _palette.WallMaterial + ?? MaterialPresets.Opaque(_palette.WallColor); + float roofSlabThickness = Constants.Roof.DefaultBaseSlabHeight; + PrimitiveBuilder.CreateBox("ParapetRoofSlab", + new Vector3(_roomSize.x / 2f, _roomSize.y + roofSlabThickness / 2f, _roomSize.z / 2f), + new Vector3(_roomSize.x, roofSlabThickness, _roomSize.z), + roofSlabMat.color, container.transform); + + return container; + } + + /// + /// Add a hip (four-slope) roof using custom mesh geometry. + /// All four sides slope inward to a central ridge that is shorter than the building length. + /// For square buildings, the ridge collapses to a point (pyramid roof). + /// A base slab sits at ceiling height and the slopes start from the top of the slab, + /// giving the roof visible thickness when viewed from below. + /// For interior ceilings, use AddCeiling() separately — the ceiling sits just below + /// the roof slab with no overlap. + /// + /// Height of the ridge peak above the ceiling in meters. + /// How far the roof eaves extend past the walls in meters. + /// If true, ridge runs along X axis. If false, along Z. Null auto-selects the longer axis. + /// Color for the sloped roof planes. + /// Material for the sloped roof planes. Null uses fallback color. + /// Height of the 3D base slab beneath the slopes. 0 disables the slab. + /// The roof container GameObject. + public GameObject AddHipRoof( + float ridgeHeight = Constants.Roof.DefaultRidgeHeight, + float overhang = Constants.Roof.DefaultOverhang, + bool? ridgeAlongX = null, + Color? roofColor = null, + Material? roofMaterial = null, + float baseSlabHeight = Constants.Roof.DefaultBaseSlabHeight) + { + bool alongX = ridgeAlongX ?? (_roomSize.x >= _roomSize.z); + GameObject container = BuildingUtilities.CreateFolder("HipRoof", _parent); + + Material? foundHipMat = MaterialPresets.FindExistingMaterial(Constants.Roof.RoofSlopeMaterialName); + if (foundHipMat == null) + { + DebugLog.Warning($"[RoofBuilder] Material '{Constants.Roof.RoofSlopeMaterialName}' not found. Using fallback color."); + } + + Material slopeMat = roofMaterial + ?? foundHipMat + ?? MaterialPresets.Opaque(roofColor ?? new Color( + Constants.Roof.DefaultRoofColorR, + Constants.Roof.DefaultRoofColorG, + Constants.Roof.DefaultRoofColorB)); + + float ceilingY = _roomSize.y; + float eaveY = ceilingY + baseSlabHeight; // slopes start on top of the slab + float ridgeY = ceilingY + ridgeHeight; + + // Base slab gives the roof visible thickness from below + if (baseSlabHeight > 0f) + { + Material slabMat = _palette.WallMaterial + ?? MaterialPresets.Opaque(_palette.WallColor); + CreateRoofBaseSlab(container.transform, ceilingY, baseSlabHeight, overhang, slabMat); + } + + if (alongX) + CreateHipAlongX(container.transform, eaveY, ridgeY, overhang, slopeMat); + else + CreateHipAlongZ(container.transform, eaveY, ridgeY, overhang, slopeMat); + + return container; + } + + #endregion + + #region Private Methods + + private static void CreateSlopeQuad( + string name, Transform parent, + Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3, + float uvWidth, float uvHeight, Material material) + { + new CustomMeshBuilder(name) + .AddVertices(v0, v1, v2, v3) // front face: 0-3 + .AddVertices(v0, v1, v2, v3) // back face: 4-7 + .AddQuad(0, 1, 2, 3) // front + .AddQuad(7, 6, 5, 4) // back (reversed winding) + .SetUVs( + new Vector2(0f, 0f), + new Vector2(uvWidth, 0f), + new Vector2(uvWidth, uvHeight), + new Vector2(0f, uvHeight), + new Vector2(0f, 0f), + new Vector2(uvWidth, 0f), + new Vector2(uvWidth, uvHeight), + new Vector2(0f, uvHeight)) + .SetMaterial(material) + .Build(parent); + } + + private void CreateHipAlongX( + Transform container, float ceilingY, float ridgeY, float overhang, + Material slopeMat) + { + float oh = overhang; + float rise = ridgeY - ceilingY; + float ridgeZ = _roomSize.z / 2f; + + // Ridge inset from each end — equal to half the short axis for equal pitch on all sides + float ridgeInset = _roomSize.z / 2f; + float ridgeMinX = ridgeInset; + float ridgeMaxX = _roomSize.x - ridgeInset; + + // Eave corners + Vector3 sw = new Vector3(-oh, ceilingY, -oh); + Vector3 se = new Vector3(_roomSize.x + oh, ceilingY, -oh); + Vector3 ne = new Vector3(_roomSize.x + oh, ceilingY, _roomSize.z + oh); + Vector3 nw = new Vector3(-oh, ceilingY, _roomSize.z + oh); + + float sideRun = ridgeZ + oh; + float sideSlopeLen = Mathf.Sqrt(rise * rise + sideRun * sideRun); + + if (ridgeMaxX > ridgeMinX) + { + // Standard hip: ridge line with positive length + Vector3 ridgeW = new Vector3(ridgeMinX, ridgeY, ridgeZ); + Vector3 ridgeE = new Vector3(ridgeMaxX, ridgeY, ridgeZ); + + float eaveLen = _roomSize.x + 2f * oh; + + // South slope (trapezoid) + CreateSlopeQuad("Slope_South", container, + se, sw, ridgeW, ridgeE, + eaveLen, sideSlopeLen, slopeMat); + + // North slope (trapezoid) + CreateSlopeQuad("Slope_North", container, + nw, ne, ridgeE, ridgeW, + eaveLen, sideSlopeLen, slopeMat); + + // Hip end slopes + float hipBaseLen = _roomSize.z + 2f * oh; + float hipRun = ridgeInset + oh; + float hipSlopeLen = Mathf.Sqrt(rise * rise + hipRun * hipRun); + + // East hip (triangle) + CreateSlopeTriangle("Hip_East", container, + ne, se, ridgeE, + hipBaseLen, hipSlopeLen, slopeMat); + + // West hip (triangle) + CreateSlopeTriangle("Hip_West", container, + sw, nw, ridgeW, + hipBaseLen, hipSlopeLen, slopeMat); + } + else + { + // Pyramid: ridge collapsed to a point + Vector3 apex = new Vector3(_roomSize.x / 2f, ridgeY, ridgeZ); + + float sideBaseLen = _roomSize.x + 2f * oh; + float hipBaseLen = _roomSize.z + 2f * oh; + float hipRun = _roomSize.x / 2f + oh; + float hipSlopeLen = Mathf.Sqrt(rise * rise + hipRun * hipRun); + + CreateSlopeTriangle("Slope_South", container, + se, sw, apex, sideBaseLen, sideSlopeLen, slopeMat); + CreateSlopeTriangle("Slope_North", container, + nw, ne, apex, sideBaseLen, sideSlopeLen, slopeMat); + CreateSlopeTriangle("Hip_East", container, + ne, se, apex, hipBaseLen, hipSlopeLen, slopeMat); + CreateSlopeTriangle("Hip_West", container, + sw, nw, apex, hipBaseLen, hipSlopeLen, slopeMat); + } + } + + private void CreateHipAlongZ( + Transform container, float ceilingY, float ridgeY, float overhang, + Material slopeMat) + { + float oh = overhang; + float rise = ridgeY - ceilingY; + float ridgeX = _roomSize.x / 2f; + + // Ridge inset from each end — equal to half the short axis for equal pitch on all sides + float ridgeInset = _roomSize.x / 2f; + float ridgeMinZ = ridgeInset; + float ridgeMaxZ = _roomSize.z - ridgeInset; + + // Eave corners + Vector3 sw = new Vector3(-oh, ceilingY, -oh); + Vector3 se = new Vector3(_roomSize.x + oh, ceilingY, -oh); + Vector3 ne = new Vector3(_roomSize.x + oh, ceilingY, _roomSize.z + oh); + Vector3 nw = new Vector3(-oh, ceilingY, _roomSize.z + oh); + + float sideRun = ridgeX + oh; + float sideSlopeLen = Mathf.Sqrt(rise * rise + sideRun * sideRun); + + if (ridgeMaxZ > ridgeMinZ) + { + // Standard hip: ridge line with positive length + Vector3 ridgeS = new Vector3(ridgeX, ridgeY, ridgeMinZ); + Vector3 ridgeN = new Vector3(ridgeX, ridgeY, ridgeMaxZ); + + float eaveLen = _roomSize.z + 2f * oh; + + // West slope (trapezoid) + CreateSlopeQuad("Slope_West", container, + sw, nw, ridgeN, ridgeS, + eaveLen, sideSlopeLen, slopeMat); + + // East slope (trapezoid) + CreateSlopeQuad("Slope_East", container, + ne, se, ridgeS, ridgeN, + eaveLen, sideSlopeLen, slopeMat); + + // Hip end slopes + float hipBaseLen = _roomSize.x + 2f * oh; + float hipRun = ridgeInset + oh; + float hipSlopeLen = Mathf.Sqrt(rise * rise + hipRun * hipRun); + + // North hip (triangle) + CreateSlopeTriangle("Hip_North", container, + nw, ne, ridgeN, + hipBaseLen, hipSlopeLen, slopeMat); + + // South hip (triangle) + CreateSlopeTriangle("Hip_South", container, + se, sw, ridgeS, + hipBaseLen, hipSlopeLen, slopeMat); + } + else + { + // Pyramid: ridge collapsed to a point + Vector3 apex = new Vector3(ridgeX, ridgeY, _roomSize.z / 2f); + + float sideBaseLen = _roomSize.z + 2f * oh; + float hipBaseLen = _roomSize.x + 2f * oh; + float hipRun = _roomSize.z / 2f + oh; + float hipSlopeLen = Mathf.Sqrt(rise * rise + hipRun * hipRun); + + CreateSlopeTriangle("Slope_West", container, + sw, nw, apex, sideBaseLen, sideSlopeLen, slopeMat); + CreateSlopeTriangle("Slope_East", container, + ne, se, apex, sideBaseLen, sideSlopeLen, slopeMat); + CreateSlopeTriangle("Hip_North", container, + nw, ne, apex, hipBaseLen, hipSlopeLen, slopeMat); + CreateSlopeTriangle("Hip_South", container, + se, sw, apex, hipBaseLen, hipSlopeLen, slopeMat); + } + } + + private static void CreateSlopeTriangle( + string name, Transform parent, + Vector3 v0, Vector3 v1, Vector3 v2, + float uvWidth, float uvHeight, Material material) + { + new CustomMeshBuilder(name) + .AddVertices(v0, v1, v2) // front face: 0-2 + .AddVertices(v0, v1, v2) // back face: 3-5 + .AddTriangle(0, 1, 2) // front + .AddTriangle(5, 4, 3) // back (reversed winding) + .SetUVs( + new Vector2(0f, 0f), + new Vector2(uvWidth, 0f), + new Vector2(uvWidth / 2f, uvHeight), + new Vector2(0f, 0f), + new Vector2(uvWidth, 0f), + new Vector2(uvWidth / 2f, uvHeight)) + .SetMaterial(material) + .Build(parent); + } + + private void CreateRoofBaseSlab( + Transform container, float ceilingY, float slabHeight, float overhang, Material material) + { + float oh = overhang; + float slabWidth = _roomSize.x + 2f * oh; + float slabDepth = _roomSize.z + 2f * oh; + float slabCenterY = ceilingY + slabHeight / 2f; + + GameObject slab = PrimitiveBuilder.CreateBox("RoofBaseSlab", + new Vector3(_roomSize.x / 2f, slabCenterY, _roomSize.z / 2f), + new Vector3(slabWidth, slabHeight, slabDepth), + material.color, container); + + ApplyMaterial(slab, material); + } + + private static void ApplyMaterial(GameObject obj, Material material) + { + Renderer r = obj.GetComponent(); + if (r != null) r.material = material; + } + + #endregion + } +} diff --git a/Utils/Constants.cs b/Utils/Constants.cs index c91a2a9..3bcf548 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -212,6 +212,74 @@ public static class Window public const float DoorStripMargin = 0.4f; } + /// + /// Roof geometry and style constants. + /// + public static class Roof + { + /// + /// Parapet wall height for the Deep preset in meters. + /// + public const float DeepParapetHeight = 0.6f; + + /// + /// Cap height for the Deep preset in meters. + /// + public const float DeepCapHeight = 0.25f; + + /// + /// Cap overhang past the parapet wall for the Deep preset in meters. + /// + public const float DeepCapOverhang = 0.15f; + + /// + /// Parapet wall height for the Shallow preset in meters. + /// + public const float ShallowParapetHeight = 0.3f; + + /// + /// Cap height for the Shallow preset in meters. + /// + public const float ShallowCapHeight = 0.15f; + + /// + /// Cap overhang past the parapet wall for the Shallow preset in meters. + /// + public const float ShallowCapOverhang = 0.05f; + + /// + /// Extra depth added to wall thickness for parapet trim depth. + /// + public const float ParapetDepthPadding = 0.1f; + + /// + /// Default ridge height above the ceiling for gable roofs in meters. + /// + public const float DefaultRidgeHeight = 2.0f; + + /// + /// Default eave overhang past walls for gable roofs in meters. + /// + public const float DefaultOverhang = 0.3f; + + /// + /// Height of the 3D base slab beneath gable and hip roofs in meters. + /// + public const float DefaultBaseSlabHeight = 0.15f; + + /// + /// Scene material name for hip roof slopes. + /// + public const string RoofSlopeMaterialName = "mansion_roof_mat"; + + /// + /// Fallback roof color RGB values when material is not found. + /// + public const float DefaultRoofColorR = 0.45f; + public const float DefaultRoofColorG = 0.35f; + public const float DefaultRoofColorB = 0.3f; + } + /// /// Terrain and area clearing constants. /// From f47b0e1aa89418d356540992f420194cb5570987 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 23 Feb 2026 13:10:12 -0500 Subject: [PATCH 15/64] fix(Building): extend N/S walls to cover corner gaps with E/W walls --- Building/Structural/WallBuilder.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index 65e144f..e6c9ce2 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -246,16 +246,19 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) private (Vector3 position, Vector3 size, bool isVertical) GetWallTransform(WallSide side) { + // N/S walls extend by wallThickness on each end to cover corner gaps with E/W walls + float extendedWidth = _roomSize.x + _wallThickness; + return side switch { WallSide.North => ( new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, _roomSize.z), - new Vector3(_roomSize.x, _roomSize.y, _wallThickness), + new Vector3(extendedWidth, _roomSize.y, _wallThickness), false ), WallSide.South => ( new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, 0f), - new Vector3(_roomSize.x, _roomSize.y, _wallThickness), + new Vector3(extendedWidth, _roomSize.y, _wallThickness), false ), WallSide.East => ( From 22d763ff7ce3a05edc31af5a4d433dabd0ad1dc3 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 23 Feb 2026 13:34:30 -0500 Subject: [PATCH 16/64] feat(Building): add AddCornerTrim for vertical corner trim strips --- Building/BuildingBuilder.cs | 15 ++++++ Building/Structural/DecorBuilder.cs | 81 +++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index e10cac6..96577f0 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -347,6 +347,21 @@ public BuildingBuilder AddCornerPillars(float width = 0.4f, Material? material = return this; } + /// + /// Add thin vertical trim strips at the four corners of the building. + /// Each corner gets two perpendicular strips forming a right angle that seamlessly + /// connects with horizontal trims (, ). + /// + /// Visible width of each trim strip on the wall face in meters + /// How far the trim protrudes past the wall surface in meters + /// Optional material override + /// This builder for chaining + public BuildingBuilder AddCornerTrim(float width = 0.3f, float depth = 0.1f, Material? material = null) + { + GetDecorBuilder().AddCornerTrim(width, depth, material); + return this; + } + /// /// Add foundation beneath the building. /// diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index b789f2d..b42eaab 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -197,6 +197,87 @@ public GameObject AddCornerPillars(float width = 0.4f, Material? material = null return container; } + /// + /// Add thin vertical trim strips at the four corners of the building. + /// Each corner gets two perpendicular strips forming a right angle, matching + /// the depth and style of horizontal trims (, ). + /// Runs the full wall height so it looks good with or without horizontal trim. + /// + /// Visible width of each trim strip on the wall face in meters + /// How far the trim protrudes past the wall surface in meters + /// Optional material override + /// The corner trim container GameObject + public GameObject AddCornerTrim(float width = 0.3f, float depth = 0.1f, Material? material = null) + { + float wallThickness = 0.2f; + float trimDepth = wallThickness + depth; + float height = _roomSize.y; + GameObject container = BuildingUtilities.CreateFolder("CornerTrim", _parent); + + Color color = _palette.TrimColor; + + // Each corner gets two perpendicular strips: + // - One on the N/S wall face at the corner end + // - One on the E/W wall face at the corner end + // Small overlap at the inner corner is invisible (same color, hidden faces). + + // NE corner — both strips centered at corner so they wrap around the edge + GameObject neN = PrimitiveBuilder.CreateBox("CornerTrim_NE_N", + new Vector3(_roomSize.x, height / 2f, _roomSize.z), + new Vector3(width, height, trimDepth), + color, container.transform); + GameObject neE = PrimitiveBuilder.CreateBox("CornerTrim_NE_E", + new Vector3(_roomSize.x, height / 2f, _roomSize.z), + new Vector3(trimDepth, height, width), + color, container.transform); + + // NW corner + GameObject nwN = PrimitiveBuilder.CreateBox("CornerTrim_NW_N", + new Vector3(0f, height / 2f, _roomSize.z), + new Vector3(width, height, trimDepth), + color, container.transform); + GameObject nwW = PrimitiveBuilder.CreateBox("CornerTrim_NW_W", + new Vector3(0f, height / 2f, _roomSize.z), + new Vector3(trimDepth, height, width), + color, container.transform); + + // SE corner + GameObject seS = PrimitiveBuilder.CreateBox("CornerTrim_SE_S", + new Vector3(_roomSize.x, height / 2f, 0f), + new Vector3(width, height, trimDepth), + color, container.transform); + GameObject seE = PrimitiveBuilder.CreateBox("CornerTrim_SE_E", + new Vector3(_roomSize.x, height / 2f, 0f), + new Vector3(trimDepth, height, width), + color, container.transform); + + // SW corner + GameObject swS = PrimitiveBuilder.CreateBox("CornerTrim_SW_S", + new Vector3(0f, height / 2f, 0f), + new Vector3(width, height, trimDepth), + color, container.transform); + GameObject swW = PrimitiveBuilder.CreateBox("CornerTrim_SW_W", + new Vector3(0f, height / 2f, 0f), + new Vector3(trimDepth, height, width), + color, container.transform); + + // Apply material + Material? mat = material ?? _palette.TrimMaterial; + if (mat != null) + { + ApplyMaterial(neN, mat); + ApplyMaterial(neE, mat); + ApplyMaterial(nwN, mat); + ApplyMaterial(nwW, mat); + ApplyMaterial(seS, mat); + ApplyMaterial(seE, mat); + ApplyMaterial(swS, mat); + ApplyMaterial(swW, mat); + } + + return container; + } + /// /// Add a solid foundation block beneath the building. /// From 5b839587f4d52bc4eaef0b0f59ec04660315535a Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 23 Feb 2026 17:57:26 -0500 Subject: [PATCH 17/64] feat(Building): add WallOpening.Offset for off-center doors and windows --- Building/BuildingBuilder.cs | 17 +++- Building/Structural/DecorBuilder.cs | 119 ++++++++++++++++------------ Building/Structural/WallBuilder.cs | 84 ++++++++++++++------ 3 files changed, 144 insertions(+), 76 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 96577f0..f3cf5a9 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -377,6 +377,7 @@ public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, fl /// /// Add stairs from ground level up to the building floor on the specified wall. + /// Automatically aligns with the door opening offset on the specified wall. /// Supports multiple visual styles: Solid (default concrete box steps), ClosedRiser (two-tone wood with risers), /// or OpenStringer (plank treads on diagonal stringer beams). /// @@ -403,7 +404,8 @@ public BuildingBuilder AddStairs( bool flushWithFloor = false, float gap = 0f) { - GetDecorBuilder().AddStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, style, flushWithFloor, gap); + float lateralOffset = GetDoorOffset(wall); + GetDecorBuilder().AddStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, style, flushWithFloor, gap, lateralOffset); return this; } @@ -623,6 +625,19 @@ private PrefabPlacer GetPrefabPlacer() return _prefabPlacer ??= new PrefabPlacer(_root.transform); } + private float GetDoorOffset(WallSide wall) + { + WallOpening? opening = wall switch + { + WallSide.North => _northOpening, + WallSide.South => _southOpening, + WallSide.East => _eastOpening, + WallSide.West => _westOpening, + _ => null + }; + return opening?.Offset ?? 0f; + } + #endregion #region Private Methods - Positioning diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index b42eaab..51a7c4f 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -331,6 +331,7 @@ public GameObject AddFoundation(float height = 2.0f, float expandX = 0f, float e /// Visual style of stairs to generate /// If true, topmost step is flush with floor level. If false (default), topmost step is one step below floor. (Solid only) /// Vertical gap between foundation edge and top step. ClosedRiser/OpenStringer default to 0 (flush). + /// Lateral offset from wall center to align stairs with an offset door opening. /// The stairs container GameObject public GameObject AddStairs( WallSide wall, @@ -342,13 +343,14 @@ public GameObject AddStairs( Material? material = null, StairStyle style = StairStyle.Solid, bool flushWithFloor = false, - float gap = 0f) + float gap = 0f, + float lateralOffset = 0f) { return style switch { - StairStyle.ClosedRiser => AddClosedRiserStairs(wall, foundationHeight, gap), - StairStyle.OpenStringer => AddOpenStringerStairs(wall, foundationHeight, gap), - _ => AddSolidStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, flushWithFloor) + StairStyle.ClosedRiser => AddClosedRiserStairs(wall, foundationHeight, gap, lateralOffset), + StairStyle.OpenStringer => AddOpenStringerStairs(wall, foundationHeight, gap, lateralOffset), + _ => AddSolidStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, flushWithFloor, lateralOffset) }; } @@ -522,7 +524,8 @@ private GameObject AddSolidStairs( float stepDepth, Color? color, Material? material, - bool flushWithFloor = false) + bool flushWithFloor = false, + float lateralOffset = 0f) { GameObject container = BuildingUtilities.CreateFolder("Stairs", _parent); Color stepColor = color ?? _palette.FloorColor; @@ -553,19 +556,19 @@ private GameObject AddSolidStairs( switch (wall) { case WallSide.North: - position = new Vector3(_roomSize.x / 2f, yCenter, _roomSize.z + perpOffset); + position = new Vector3(_roomSize.x / 2f + lateralOffset, yCenter, _roomSize.z + perpOffset); size = new Vector3(width, height, stepDepth); break; case WallSide.South: - position = new Vector3(_roomSize.x / 2f, yCenter, -perpOffset); + position = new Vector3(_roomSize.x / 2f + lateralOffset, yCenter, -perpOffset); size = new Vector3(width, height, stepDepth); break; case WallSide.East: - position = new Vector3(_roomSize.x + perpOffset, yCenter, _roomSize.z / 2f); + position = new Vector3(_roomSize.x + perpOffset, yCenter, _roomSize.z / 2f + lateralOffset); size = new Vector3(stepDepth, height, width); break; case WallSide.West: - position = new Vector3(-perpOffset, yCenter, _roomSize.z / 2f); + position = new Vector3(-perpOffset, yCenter, _roomSize.z / 2f + lateralOffset); size = new Vector3(stepDepth, height, width); break; default: @@ -585,7 +588,7 @@ private GameObject AddSolidStairs( return container; } - private GameObject AddClosedRiserStairs(WallSide wall, float foundationHeight, float gap) + private GameObject AddClosedRiserStairs(WallSide wall, float foundationHeight, float gap, float lateralOffset = 0f) { GameObject container = BuildingUtilities.CreateFolder("Stairs_ClosedRiser", _parent); @@ -627,27 +630,27 @@ private GameObject AddClosedRiserStairs(WallSide wall, float foundationHeight, f switch (wall) { case WallSide.North: - riserPos = new Vector3(_roomSize.x / 2f, yCenter, _roomSize.z + perpOffset); + riserPos = new Vector3(_roomSize.x / 2f + lateralOffset, yCenter, _roomSize.z + perpOffset); riserSize = new Vector3(width, height, stepDepth); - treadPos = new Vector3(_roomSize.x / 2f, treadY, _roomSize.z + perpOffset); + treadPos = new Vector3(_roomSize.x / 2f + lateralOffset, treadY, _roomSize.z + perpOffset); treadSize = new Vector3(width + treadOverhangWidth, treadThickness, stepDepth + treadOverhangDepth); break; case WallSide.South: - riserPos = new Vector3(_roomSize.x / 2f, yCenter, -perpOffset); + riserPos = new Vector3(_roomSize.x / 2f + lateralOffset, yCenter, -perpOffset); riserSize = new Vector3(width, height, stepDepth); - treadPos = new Vector3(_roomSize.x / 2f, treadY, -perpOffset); + treadPos = new Vector3(_roomSize.x / 2f + lateralOffset, treadY, -perpOffset); treadSize = new Vector3(width + treadOverhangWidth, treadThickness, stepDepth + treadOverhangDepth); break; case WallSide.East: - riserPos = new Vector3(_roomSize.x + perpOffset, yCenter, _roomSize.z / 2f); + riserPos = new Vector3(_roomSize.x + perpOffset, yCenter, _roomSize.z / 2f + lateralOffset); riserSize = new Vector3(stepDepth, height, width); - treadPos = new Vector3(_roomSize.x + perpOffset, treadY, _roomSize.z / 2f); + treadPos = new Vector3(_roomSize.x + perpOffset, treadY, _roomSize.z / 2f + lateralOffset); treadSize = new Vector3(stepDepth + treadOverhangDepth, treadThickness, width + treadOverhangWidth); break; case WallSide.West: - riserPos = new Vector3(-perpOffset, yCenter, _roomSize.z / 2f); + riserPos = new Vector3(-perpOffset, yCenter, _roomSize.z / 2f + lateralOffset); riserSize = new Vector3(stepDepth, height, width); - treadPos = new Vector3(-perpOffset, treadY, _roomSize.z / 2f); + treadPos = new Vector3(-perpOffset, treadY, _roomSize.z / 2f + lateralOffset); treadSize = new Vector3(stepDepth + treadOverhangDepth, treadThickness, width + treadOverhangWidth); break; default: @@ -669,7 +672,7 @@ private GameObject AddClosedRiserStairs(WallSide wall, float foundationHeight, f return container; } - private GameObject AddOpenStringerStairs(WallSide wall, float foundationHeight, float gap) + private GameObject AddOpenStringerStairs(WallSide wall, float foundationHeight, float gap, float lateralOffset = 0f) { GameObject container = BuildingUtilities.CreateFolder("Stairs_OpenStringer", _parent); @@ -705,19 +708,19 @@ private GameObject AddOpenStringerStairs(WallSide wall, float foundationHeight, switch (wall) { case WallSide.North: - treadPos = new Vector3(_roomSize.x / 2f, treadY, _roomSize.z + perpOffset); + treadPos = new Vector3(_roomSize.x / 2f + lateralOffset, treadY, _roomSize.z + perpOffset); treadSize = new Vector3(width + treadOverhang, treadThickness, stepDepth + 0.04f); break; case WallSide.South: - treadPos = new Vector3(_roomSize.x / 2f, treadY, -perpOffset); + treadPos = new Vector3(_roomSize.x / 2f + lateralOffset, treadY, -perpOffset); treadSize = new Vector3(width + treadOverhang, treadThickness, stepDepth + 0.04f); break; case WallSide.East: - treadPos = new Vector3(_roomSize.x + perpOffset, treadY, _roomSize.z / 2f); + treadPos = new Vector3(_roomSize.x + perpOffset, treadY, _roomSize.z / 2f + lateralOffset); treadSize = new Vector3(stepDepth + 0.04f, treadThickness, width + treadOverhang); break; case WallSide.West: - treadPos = new Vector3(-perpOffset, treadY, _roomSize.z / 2f); + treadPos = new Vector3(-perpOffset, treadY, _roomSize.z / 2f + lateralOffset); treadSize = new Vector3(stepDepth + 0.04f, treadThickness, width + treadOverhang); break; default: @@ -785,20 +788,20 @@ private GameObject AddOpenStringerStairs(WallSide wall, float foundationHeight, switch (wall) { case WallSide.North: - beamPos = new Vector3(_roomSize.x / 2f + signedOffset, midY, _roomSize.z + midPerp); + beamPos = new Vector3(_roomSize.x / 2f + lateralOffset + signedOffset, midY, _roomSize.z + midPerp); beamRot = Quaternion.Euler(angleDeg + 90f, 0f, 0f); break; case WallSide.South: - beamPos = new Vector3(_roomSize.x / 2f + signedOffset, midY, -midPerp); + beamPos = new Vector3(_roomSize.x / 2f + lateralOffset + signedOffset, midY, -midPerp); beamRot = Quaternion.Euler(-angleDeg - 90f, 0f, 0f); break; case WallSide.East: - beamPos = new Vector3(_roomSize.x + midPerp, midY, _roomSize.z / 2f + signedOffset); + beamPos = new Vector3(_roomSize.x + midPerp, midY, _roomSize.z / 2f + lateralOffset + signedOffset); beamSize = new Vector3(beamHeight, stringerLength, beamWidth); beamRot = Quaternion.Euler(0f, 0f, -angleDeg - 90f); break; case WallSide.West: - beamPos = new Vector3(-midPerp, midY, _roomSize.z / 2f + signedOffset); + beamPos = new Vector3(-midPerp, midY, _roomSize.z / 2f + lateralOffset + signedOffset); beamSize = new Vector3(beamHeight, stringerLength, beamWidth); beamRot = Quaternion.Euler(0f, 0f, angleDeg + 90f); break; @@ -836,40 +839,46 @@ private void CreateMoldingSegments( return; } - float segmentLength = (wallLength - opening!.Width) / 2f; - if (segmentLength <= 0f) return; + float doorOffset = opening!.Offset; - // Offset from strip center to each segment center - float offsetFromCenter = (opening.Width + segmentLength) / 2f; + // Asymmetric segment lengths around the offset door + float leftLength = (wallLength - opening.Width) / 2f + doorOffset; + float rightLength = (wallLength - opening.Width) / 2f - doorOffset; if (isZAxis) { // Strip runs along Z (East/West walls) - Vector3 segSize = new Vector3(size.x, size.y, segmentLength); - Vector3 lowZ = center + Vector3.back * offsetFromCenter; - Vector3 highZ = center + Vector3.forward * offsetFromCenter; - - GameObject left = PrimitiveBuilder.CreateBox($"{name}_L", lowZ, segSize, color, container.transform); - GameObject right = PrimitiveBuilder.CreateBox($"{name}_R", highZ, segSize, color, container.transform); - if (material != null) + if (leftLength > 0f) + { + Vector3 segSize = new Vector3(size.x, size.y, leftLength); + Vector3 lowZ = center + Vector3.back * (wallLength / 2f - leftLength / 2f); + GameObject left = PrimitiveBuilder.CreateBox($"{name}_L", lowZ, segSize, color, container.transform); + if (material != null) ApplyMaterial(left, material); + } + if (rightLength > 0f) { - ApplyMaterial(left, material); - ApplyMaterial(right, material); + Vector3 segSize = new Vector3(size.x, size.y, rightLength); + Vector3 highZ = center + Vector3.forward * (wallLength / 2f - rightLength / 2f); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_R", highZ, segSize, color, container.transform); + if (material != null) ApplyMaterial(right, material); } } else { // Strip runs along X (North/South walls) - Vector3 segSize = new Vector3(segmentLength, size.y, size.z); - Vector3 lowX = center + Vector3.left * offsetFromCenter; - Vector3 highX = center + Vector3.right * offsetFromCenter; - - GameObject left = PrimitiveBuilder.CreateBox($"{name}_L", lowX, segSize, color, container.transform); - GameObject right = PrimitiveBuilder.CreateBox($"{name}_R", highX, segSize, color, container.transform); - if (material != null) + if (leftLength > 0f) + { + Vector3 segSize = new Vector3(leftLength, size.y, size.z); + Vector3 lowX = center + Vector3.left * (wallLength / 2f - leftLength / 2f); + GameObject left = PrimitiveBuilder.CreateBox($"{name}_L", lowX, segSize, color, container.transform); + if (material != null) ApplyMaterial(left, material); + } + if (rightLength > 0f) { - ApplyMaterial(left, material); - ApplyMaterial(right, material); + Vector3 segSize = new Vector3(rightLength, size.y, size.z); + Vector3 highX = center + Vector3.right * (wallLength / 2f - rightLength / 2f); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_R", highX, segSize, color, container.transform); + if (material != null) ApplyMaterial(right, material); } } } @@ -931,6 +940,12 @@ private void CreateDoorFrame( float doorWidth = opening.Width; float doorHeight = opening.Height; + // Shift frame to match door offset + Vector3 doorShift = isVertical + ? new Vector3(0f, 0f, opening.Offset) + : new Vector3(opening.Offset, 0f, 0f); + Vector3 doorCenter = wallCenter + doorShift; + float jambYOffset = -(wallHeight - doorHeight) / 2f; float sideOffset = doorWidth / 2f + frameWidth / 2f; @@ -940,13 +955,13 @@ private void CreateDoorFrame( : new Vector3(frameWidth, doorHeight, trimDepth); // Left jamb - Vector3 leftJambPos = wallCenter + (isVertical + Vector3 leftJambPos = doorCenter + (isVertical ? new Vector3(0f, jambYOffset, -sideOffset) : new Vector3(-sideOffset, jambYOffset, 0f)); GameObject leftJamb = PrimitiveBuilder.CreateBox($"{name}_Left", leftJambPos, jambSize, color, container.transform); // Right jamb - Vector3 rightJambPos = wallCenter + (isVertical + Vector3 rightJambPos = doorCenter + (isVertical ? new Vector3(0f, jambYOffset, sideOffset) : new Vector3(sideOffset, jambYOffset, 0f)); GameObject rightJamb = PrimitiveBuilder.CreateBox($"{name}_Right", rightJambPos, jambSize, color, container.transform); @@ -958,7 +973,7 @@ private void CreateDoorFrame( ? new Vector3(trimDepth, frameWidth, headerWidth) : new Vector3(headerWidth, frameWidth, trimDepth); GameObject header = PrimitiveBuilder.CreateBox($"{name}_Top", - wallCenter + new Vector3(0f, headerYOffset, 0f), headerSize, color, container.transform); + doorCenter + new Vector3(0f, headerYOffset, 0f), headerSize, color, container.transform); if (material != null) { diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index e6c9ce2..09f0e37 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -70,19 +70,26 @@ public sealed class WallOpening public float DividerWidth { get; set; } = Constants.Window.DefaultDividerWidth; /// Optional glass material override. When null, uses Materials.WindowGlass. public Material? GlassMaterial { get; set; } + /// + /// Lateral offset from center along the wall in meters. + /// Positive shifts toward the right/forward end, negative toward left/back. + /// + public float Offset { get; set; } = 0f; /// /// Creates a door opening configuration. /// /// The door width in meters (default 2.0). /// The door height in meters (default 2.2). + /// Lateral offset from center along the wall in meters (default 0, centered). /// A new WallOpening configured as a door. - public static WallOpening Door(float width = 2.0f, float height = 2.2f) => new() + public static WallOpening Door(float width = 2.0f, float height = 2.2f, float offset = 0f) => new() { Type = WallOpeningType.Door, Width = width, Height = height, - BottomOffset = 0f + BottomOffset = 0f, + Offset = offset }; /// @@ -94,11 +101,12 @@ public sealed class WallOpening /// Number of window panes to distribute across the opening width (default 1). /// Width of wall dividers between adjacent panes in meters (default 0.15). /// Optional glass material override. Defaults to Materials.WindowGlass when null. + /// Lateral offset from center along the wall in meters (default 0, centered). /// A new WallOpening configured as a window. public static WallOpening Window( float width = 2.5f, float height = 2.0f, float sillHeight = 0.8f, int count = 1, float dividerWidth = Constants.Window.DefaultDividerWidth, - Material? glassMaterial = null) => new() + Material? glassMaterial = null, float offset = 0f) => new() { Type = WallOpeningType.Window, Width = width, @@ -106,7 +114,8 @@ public static WallOpening Window( BottomOffset = sillHeight, Count = count, DividerWidth = dividerWidth, - GlassMaterial = glassMaterial + GlassMaterial = glassMaterial, + Offset = offset }; /// @@ -290,22 +299,39 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w float wallHeight = wallSize.y; float doorWidth = opening.Width; float doorHeight = opening.Height; - float sideWallWidth = (wallWidth - doorWidth) / 2f; + float offset = opening.Offset; - Vector3 leftOffset = isVertical ? Vector3.back * (doorWidth / 2f + sideWallWidth / 2f) : Vector3.left * (doorWidth / 2f + sideWallWidth / 2f); - Vector3 rightOffset = isVertical ? Vector3.forward * (doorWidth / 2f + sideWallWidth / 2f) : Vector3.right * (doorWidth / 2f + sideWallWidth / 2f); + // Positive offset shifts door toward positive axis (right/forward) + // Left (negative direction) gets bigger, right gets smaller + float leftWidth = (wallWidth - doorWidth) / 2f + offset; + float rightWidth = (wallWidth - doorWidth) / 2f - offset; + + // Door center shifted by offset along the wall axis + Vector3 doorShift = isVertical ? Vector3.forward * offset : Vector3.right * offset; // Left segment - Vector3 leftSize = isVertical - ? new Vector3(_wallThickness, wallHeight, sideWallWidth) - : new Vector3(sideWallWidth, wallHeight, _wallThickness); - GameObject left = PrimitiveBuilder.CreateBox($"{name}_Left", wallCenter + leftOffset, leftSize, _palette.WallColor, container.transform); - ApplyWallMaterial(left); + if (leftWidth > 0f) + { + float leftCenter = doorWidth / 2f + leftWidth / 2f; + Vector3 leftOffset = isVertical ? Vector3.back * leftCenter : Vector3.left * leftCenter; + Vector3 leftSize = isVertical + ? new Vector3(_wallThickness, wallHeight, leftWidth) + : new Vector3(leftWidth, wallHeight, _wallThickness); + GameObject left = PrimitiveBuilder.CreateBox($"{name}_Left", wallCenter + doorShift + leftOffset, leftSize, _palette.WallColor, container.transform); + ApplyWallMaterial(left); + } // Right segment - Vector3 rightSize = leftSize; - GameObject right = PrimitiveBuilder.CreateBox($"{name}_Right", wallCenter + rightOffset, rightSize, _palette.WallColor, container.transform); - ApplyWallMaterial(right); + if (rightWidth > 0f) + { + float rightCenter = doorWidth / 2f + rightWidth / 2f; + Vector3 rightOffset = isVertical ? Vector3.forward * rightCenter : Vector3.right * rightCenter; + Vector3 rightSize = isVertical + ? new Vector3(_wallThickness, wallHeight, rightWidth) + : new Vector3(rightWidth, wallHeight, _wallThickness); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_Right", wallCenter + doorShift + rightOffset, rightSize, _palette.WallColor, container.transform); + ApplyWallMaterial(right); + } // Top segment (wall above door) float topHeight = wallHeight - doorHeight; @@ -316,7 +342,7 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w : new Vector3(doorWidth, topHeight, _wallThickness); float topCenterY = wallHeight / 2f - topHeight / 2f; Vector3 topOffset = Vector3.up * topCenterY; - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + topOffset, topSize, _palette.WallColor, container.transform); + GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + doorShift + topOffset, topSize, _palette.WallColor, container.transform); ApplyWallMaterial(top); } @@ -331,15 +357,27 @@ private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, float wallHeight = wallSize.y; float doorWidth = opening.Width; float doorHeight = opening.Height; - float fullSideWidth = (wallWidth - doorWidth) / 2f; + float offset = opening.Offset; + + float leftSideWidth = (wallWidth - doorWidth) / 2f + offset; + float rightSideWidth = (wallWidth - doorWidth) / 2f - offset; + + Vector3 doorShift = isVertical ? Vector3.forward * offset : Vector3.right * offset; + Vector3 shiftedCenter = wallCenter + doorShift; // Left side (may contain a window) - BuildDoorSideSegment(name, "_Left", wallCenter, doorWidth, wallHeight, - fullSideWidth, opening.LeftWindow, isVertical, true, container.transform); + if (leftSideWidth > 0f) + { + BuildDoorSideSegment(name, "_Left", shiftedCenter, doorWidth, wallHeight, + leftSideWidth, opening.LeftWindow, isVertical, true, container.transform); + } // Right side (may contain a window) - BuildDoorSideSegment(name, "_Right", wallCenter, doorWidth, wallHeight, - fullSideWidth, opening.RightWindow, isVertical, false, container.transform); + if (rightSideWidth > 0f) + { + BuildDoorSideSegment(name, "_Right", shiftedCenter, doorWidth, wallHeight, + rightSideWidth, opening.RightWindow, isVertical, false, container.transform); + } // Top segment (wall above door) — same as CreateWallWithDoor float topHeight = wallHeight - doorHeight; @@ -350,7 +388,7 @@ private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, : new Vector3(doorWidth, topHeight, _wallThickness); float topCenterY = wallHeight / 2f - topHeight / 2f; Vector3 topOffset = Vector3.up * topCenterY; - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + topOffset, topSize, _palette.WallColor, container.transform); + GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", shiftedCenter + topOffset, topSize, _palette.WallColor, container.transform); ApplyWallMaterial(top); } @@ -410,7 +448,7 @@ private GameObject CreateWallWithWindow(string name, Vector3 wallCenter, Vector3 { GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); float wallWidth = isVertical ? wallSize.z : wallSize.x; - CreateWindowInSection(name, wallCenter, wallWidth, wallSize.y, opening, isVertical, container.transform); + CreateWindowInSection(name, wallCenter, wallWidth, wallSize.y, opening, isVertical, container.transform, opening.Offset); return container; } From 75b12970c548e7a627a40d3037c11e5ef810d3bc Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 23 Feb 2026 17:58:15 -0500 Subject: [PATCH 18/64] feat(Building): add interior walls with doorway tracking for NavMesh --- Building/BuildingBuilder.cs | 59 ++++ Building/Structural/InteriorWallBuilder.cs | 335 +++++++++++++++++++++ Utils/Constants.cs | 16 + 3 files changed, 410 insertions(+) create mode 100644 Building/Structural/InteriorWallBuilder.cs diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index f3cf5a9..14ddb01 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using S1MAPI.Building.Config; using S1MAPI.Building.Structural; using S1MAPI.Building.Interior; @@ -39,6 +40,7 @@ public sealed class BuildingBuilder private DecorBuilder? _decorBuilder; private RoofBuilder? _roofBuilder; private PrefabPlacer? _prefabPlacer; + private InteriorWallBuilder? _interiorWallBuilder; // Stored wall openings for cross-builder communication (e.g., base molding gap) private WallOpening? _northOpening; @@ -243,6 +245,56 @@ public BuildingBuilder AddWalls( #endregion + #region Interior Walls + + /// + /// Add an interior wall spanning a sub-region of the room. + /// + /// Axis the wall runs along (X or Z) + /// Position on the perpendicular axis (Z for X-axis walls, X for Z-axis walls) + /// Start coordinate along the wall's axis + /// End coordinate along the wall's axis + /// Optional opening (door or window) centered in the wall + /// Optional wall color override (defaults to palette wall color) + /// Optional wall material override (defaults to palette wall material) + /// This builder for chaining + public BuildingBuilder AddInteriorWall( + InteriorWallAxis axis, float position, float from, float to, + WallOpening? opening = null, + Color? color = null, Material? material = null) + { + var def = new InteriorWallDefinition(axis, position, from, to, opening, color, material); + GetInteriorWallBuilder().BuildInteriorWall(def); + return this; + } + + /// + /// Add an interior wall spanning the full room width along the specified axis. + /// + /// Axis the wall runs along (X or Z) + /// Position on the perpendicular axis (Z for X-axis walls, X for Z-axis walls) + /// Optional opening (door or window) centered in the wall + /// Optional wall color override (defaults to palette wall color) + /// Optional wall material override (defaults to palette wall material) + /// This builder for chaining + public BuildingBuilder AddInteriorWall( + InteriorWallAxis axis, float position, + WallOpening? opening = null, + Color? color = null, Material? material = null) + { + float axisMax = axis == InteriorWallAxis.X ? _roomSize.x : _roomSize.z; + return AddInteriorWall(axis, position, 0f, axisMax, opening, color, material); + } + + /// + /// Doorway positions recorded from all interior walls. + /// Each entry provides center, dimensions, and orientation for future NavMesh link generation. + /// + public IReadOnlyList InteriorDoorways => + _interiorWallBuilder?.Doorways ?? (IReadOnlyList)System.Array.Empty(); + + #endregion + #region Decoration /// @@ -587,6 +639,7 @@ private void InvalidateBuilders() _lightingBuilder = null; _decorBuilder = null; _roofBuilder = null; + _interiorWallBuilder = null; // PrefabPlacer doesn't depend on room size } @@ -620,6 +673,12 @@ private RoofBuilder GetRoofBuilder() return _roofBuilder ??= new RoofBuilder(_root.transform, _roomSize, _config.WallThickness, _config.Palette); } + private InteriorWallBuilder GetInteriorWallBuilder() + { + return _interiorWallBuilder ??= new InteriorWallBuilder( + _root.transform, _roomSize, _config.WallThickness, _config.Palette); + } + private PrefabPlacer GetPrefabPlacer() { return _prefabPlacer ??= new PrefabPlacer(_root.transform); diff --git a/Building/Structural/InteriorWallBuilder.cs b/Building/Structural/InteriorWallBuilder.cs new file mode 100644 index 0000000..28ab479 --- /dev/null +++ b/Building/Structural/InteriorWallBuilder.cs @@ -0,0 +1,335 @@ +using System.Collections.Generic; +using S1MAPI.Building.Config; +using S1MAPI.ProceduralMesh; +using S1MAPI.Utils; +using UnityEngine; + +namespace S1MAPI.Building.Structural +{ + /// + /// Specifies the axis an interior wall runs along. + /// + public enum InteriorWallAxis + { + /// Wall runs along X axis; is the Z coordinate. + X, + /// Wall runs along Z axis; is the X coordinate. + Z + } + + /// + /// Defines an interior wall placement: axis, position, span, and optional opening. + /// + public sealed class InteriorWallDefinition + { + /// Which axis the wall runs along. + public InteriorWallAxis Axis { get; } + + /// + /// Position on the perpendicular axis (Z coordinate for , + /// X coordinate for ). + /// + public float Position { get; } + + /// Start coordinate along the wall's own axis. + public float From { get; } + + /// End coordinate along the wall's own axis. + public float To { get; } + + /// Optional opening (door or window) centered in the wall. + public WallOpening? Opening { get; } + + /// Optional wall color override. + public Color? Color { get; } + + /// Optional wall material override. + public Material? Material { get; } + + /// + /// Create an interior wall definition. + /// + /// Axis the wall runs along + /// Position on the perpendicular axis + /// Start coordinate along the wall axis + /// End coordinate along the wall axis + /// Optional wall opening + /// Optional color override + /// Optional material override + public InteriorWallDefinition( + InteriorWallAxis axis, float position, float from, float to, + WallOpening? opening = null, Color? color = null, Material? material = null) + { + Axis = axis; + Position = position; + From = from; + To = to; + Opening = opening; + Color = color; + Material = material; + } + } + + /// + /// Records the position and dimensions of a doorway in an interior wall. + /// Used for future NavMesh link generation. + /// + public sealed class DoorwayInfo + { + /// Center of the doorway in local building coordinates. + public Vector3 Center { get; } + + /// Width of the doorway opening in meters. + public float Width { get; } + + /// Height of the doorway opening in meters. + public float Height { get; } + + /// + /// True if the wall's normal faces along Z (i.e., the wall runs along X). + /// Used to determine NavMeshLink start/end offset direction. + /// + public bool FacesAlongZ { get; } + + /// Thickness of the wall containing this doorway. + public float WallThickness { get; } + + /// + /// Create a doorway info record. + /// + public DoorwayInfo(Vector3 center, float width, float height, bool facesAlongZ, float wallThickness) + { + Center = center; + Width = width; + Height = height; + FacesAlongZ = facesAlongZ; + WallThickness = wallThickness; + } + } + + /// + /// Builds interior walls with optional door openings. + /// Tracks doorway positions for future NavMesh link generation. + /// + public sealed class InteriorWallBuilder + { + #region Fields + + private readonly Transform _parent; + private readonly Vector3 _roomSize; + private readonly float _wallThickness; + private readonly BuildingPalette _palette; + private GameObject? _container; + private readonly List _doorways = new List(); + + #endregion + + #region Constructor + + /// + /// Create a new interior wall builder. + /// + /// Parent transform for interior walls + /// Room dimensions (width, height, depth) + /// Wall thickness in meters + /// Material and color palette + public InteriorWallBuilder(Transform parent, Vector3 roomSize, float wallThickness, BuildingPalette palette) + { + _parent = parent; + _roomSize = roomSize; + _wallThickness = wallThickness; + _palette = palette; + } + + #endregion + + #region Public API + + /// + /// Recorded doorway positions from all interior walls built so far. + /// + public IReadOnlyList Doorways => + _doorways; + + /// + /// Build an interior wall from a definition. + /// + /// Interior wall definition + /// The wall GameObject, or null if validation fails + public GameObject? BuildInteriorWall(InteriorWallDefinition def) + { + if (!Validate(def)) + return null; + + _container ??= BuildingUtilities.CreateFolder("InteriorWalls", _parent); + + float wallLength = def.To - def.From; + Color wallColor = def.Color ?? _palette.WallColor; + Material? wallMaterial = def.Material ?? _palette.WallMaterial; + + var (center, size, isVertical) = GetWallTransform(def); + + string wallName = $"InteriorWall_{def.Axis}_{def.Position:F1}"; + + if (def.Opening == null || def.Opening.Type == WallOpeningType.None) + { + return CreateSolidWall(wallName, center, size, wallColor, wallMaterial); + } + + if (def.Opening.Type == WallOpeningType.Door) + { + return CreateWallWithDoor(wallName, center, size, def.Opening, isVertical, wallColor, wallMaterial); + } + + // Fallback: solid wall for unsupported opening types + return CreateSolidWall(wallName, center, size, wallColor, wallMaterial); + } + + #endregion + + #region Private Methods — Validation + + private bool Validate(InteriorWallDefinition def) + { + float axisMax = def.Axis == InteriorWallAxis.X ? _roomSize.x : _roomSize.z; + float perpMax = def.Axis == InteriorWallAxis.X ? _roomSize.z : _roomSize.x; + + if (def.Position < 0f || def.Position > perpMax) + { + DebugLog.Warning($"[InteriorWallBuilder] Position {def.Position} out of room bounds (0–{perpMax}). Skipping wall."); + return false; + } + + if (def.From >= def.To) + { + DebugLog.Warning($"[InteriorWallBuilder] From ({def.From}) >= To ({def.To}). Skipping wall."); + return false; + } + + float wallLength = def.To - def.From; + if (wallLength < Constants.InteriorWall.MinWallLength) + { + DebugLog.Warning($"[InteriorWallBuilder] Wall length {wallLength:F2}m < minimum {Constants.InteriorWall.MinWallLength}m. Skipping wall."); + return false; + } + + if (def.Opening != null && def.Opening.Type == WallOpeningType.Door && def.Opening.Width >= wallLength) + { + DebugLog.Warning($"[InteriorWallBuilder] Door width ({def.Opening.Width}) >= wall length ({wallLength:F2}). Skipping wall."); + return false; + } + + return true; + } + + #endregion + + #region Private Methods — Geometry + + private (Vector3 center, Vector3 size, bool isVertical) GetWallTransform(InteriorWallDefinition def) + { + float wallLength = def.To - def.From; + float midAlong = (def.From + def.To) / 2f; + + // Axis.X: wall runs along X, position is Z, normal faces Z → isVertical = false + // Axis.Z: wall runs along Z, position is X, normal faces X → isVertical = true + if (def.Axis == InteriorWallAxis.X) + { + Vector3 center = new Vector3(midAlong, _roomSize.y / 2f, def.Position); + Vector3 size = new Vector3(wallLength, _roomSize.y, _wallThickness); + return (center, size, false); + } + else + { + Vector3 center = new Vector3(def.Position, _roomSize.y / 2f, midAlong); + Vector3 size = new Vector3(_wallThickness, _roomSize.y, wallLength); + return (center, size, true); + } + } + + private GameObject CreateSolidWall(string name, Vector3 center, Vector3 size, Color color, Material? material) + { + GameObject wall = PrimitiveBuilder.CreateBox(name, center, size, color, _container!.transform); + ApplyMaterial(wall, material); + return wall; + } + + private GameObject CreateWallWithDoor( + string name, Vector3 wallCenter, Vector3 wallSize, + WallOpening opening, bool isVertical, + Color color, Material? material) + { + GameObject container = BuildingUtilities.CreateFolder(name, _container!.transform); + + float wallWidth = isVertical ? wallSize.z : wallSize.x; + float wallHeight = wallSize.y; + float doorWidth = opening.Width; + float doorHeight = opening.Height; + float offset = opening.Offset; + + // Positive offset shifts door toward positive axis (right/forward) + // Left (negative direction) gets bigger, right gets smaller + float leftWidth = (wallWidth - doorWidth) / 2f + offset; + float rightWidth = (wallWidth - doorWidth) / 2f - offset; + + // Door center shifted by offset along the wall axis + Vector3 doorShift = isVertical ? Vector3.forward * offset : Vector3.right * offset; + Vector3 shiftedCenter = wallCenter + doorShift; + + // Left segment + if (leftWidth > Constants.InteriorWall.SegmentThreshold) + { + float leftCenter = doorWidth / 2f + leftWidth / 2f; + Vector3 leftOffset = isVertical ? Vector3.back * leftCenter : Vector3.left * leftCenter; + Vector3 leftSize = isVertical + ? new Vector3(_wallThickness, wallHeight, leftWidth) + : new Vector3(leftWidth, wallHeight, _wallThickness); + GameObject left = PrimitiveBuilder.CreateBox($"{name}_Left", shiftedCenter + leftOffset, leftSize, color, container.transform); + ApplyMaterial(left, material); + } + + // Right segment + if (rightWidth > Constants.InteriorWall.SegmentThreshold) + { + float rightCenter = doorWidth / 2f + rightWidth / 2f; + Vector3 rightOffset = isVertical ? Vector3.forward * rightCenter : Vector3.right * rightCenter; + Vector3 rightSize = isVertical + ? new Vector3(_wallThickness, wallHeight, rightWidth) + : new Vector3(rightWidth, wallHeight, _wallThickness); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_Right", shiftedCenter + rightOffset, rightSize, color, container.transform); + ApplyMaterial(right, material); + } + + // Top segment (wall above door) + float topHeight = wallHeight - doorHeight; + if (topHeight > Constants.InteriorWall.SegmentThreshold) + { + Vector3 topSize = isVertical + ? new Vector3(_wallThickness, topHeight, doorWidth) + : new Vector3(doorWidth, topHeight, _wallThickness); + float topCenterY = wallHeight / 2f - topHeight / 2f; + Vector3 topOffset = Vector3.up * topCenterY; + GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", shiftedCenter + topOffset, topSize, color, container.transform); + ApplyMaterial(top, material); + } + + // Record doorway for future NavMesh (use shifted position) + bool facesAlongZ = !isVertical; // Axis.X wall faces Z + Vector3 doorCenter = new Vector3(shiftedCenter.x, doorHeight / 2f, shiftedCenter.z); + _doorways.Add(new DoorwayInfo(doorCenter, doorWidth, doorHeight, facesAlongZ, _wallThickness)); + + return container; + } + + private void ApplyMaterial(GameObject go, Material? material) + { + if (material != null) + { + Renderer r = go.GetComponent(); + if (r != null) r.material = material; + } + } + + #endregion + } +} diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 3bcf548..6d8bb74 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -280,6 +280,22 @@ public static class Roof public const float DefaultRoofColorB = 0.3f; } + /// + /// Interior wall geometry constants. + /// + public static class InteriorWall + { + /// + /// Minimum wall length in meters. + /// + public const float MinWallLength = 0.5f; + + /// + /// Geometry threshold below which wall segments are not created. + /// + public const float SegmentThreshold = 0.01f; + } + /// /// Terrain and area clearing constants. /// From 40da7b14e065a4985ce89cc639d0ca36d3269c3b Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Feb 2026 21:49:13 -0500 Subject: [PATCH 19/64] feat(Building): add WithInteriorWallLayer for placement raycast control --- Building/BuildingBuilder.cs | 18 ++++++++++++++- Building/Structural/InteriorWallBuilder.cs | 26 +++++++++++++++++----- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 14ddb01..c787d3d 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -42,6 +42,9 @@ public sealed class BuildingBuilder private PrefabPlacer? _prefabPlacer; private InteriorWallBuilder? _interiorWallBuilder; + // Interior wall physics layer (-1 = default layer, no change) + private int _interiorWallLayer = -1; + // Stored wall openings for cross-builder communication (e.g., base molding gap) private WallOpening? _northOpening; private WallOpening? _southOpening; @@ -247,6 +250,19 @@ public BuildingBuilder AddWalls( #region Interior Walls + /// + /// Set the physics layer for interior wall GameObjects. + /// Use this to place interior walls on a layer outside the placement raycast mask + /// so the ghost model passes through them while players still physically collide. + /// + /// Unity layer index (0–31). -1 leaves walls on the default layer. + /// This builder for chaining + public BuildingBuilder WithInteriorWallLayer(int layer) + { + _interiorWallLayer = layer; + return this; + } + /// /// Add an interior wall spanning a sub-region of the room. /// @@ -676,7 +692,7 @@ private RoofBuilder GetRoofBuilder() private InteriorWallBuilder GetInteriorWallBuilder() { return _interiorWallBuilder ??= new InteriorWallBuilder( - _root.transform, _roomSize, _config.WallThickness, _config.Palette); + _root.transform, _roomSize, _config.WallThickness, _config.Palette, _interiorWallLayer); } private PrefabPlacer GetPrefabPlacer() diff --git a/Building/Structural/InteriorWallBuilder.cs b/Building/Structural/InteriorWallBuilder.cs index 28ab479..e113de6 100644 --- a/Building/Structural/InteriorWallBuilder.cs +++ b/Building/Structural/InteriorWallBuilder.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using S1MAPI.Building.Config; +using S1MAPI.Extensions; using S1MAPI.ProceduralMesh; using S1MAPI.Utils; using UnityEngine; @@ -119,6 +120,7 @@ public sealed class InteriorWallBuilder private readonly Vector3 _roomSize; private readonly float _wallThickness; private readonly BuildingPalette _palette; + private readonly int _layer; private GameObject? _container; private readonly List _doorways = new List(); @@ -133,12 +135,14 @@ public sealed class InteriorWallBuilder /// Room dimensions (width, height, depth) /// Wall thickness in meters /// Material and color palette - public InteriorWallBuilder(Transform parent, Vector3 roomSize, float wallThickness, BuildingPalette palette) + /// Physics layer for interior wall GameObjects. -1 (default) leaves them on the default layer. + public InteriorWallBuilder(Transform parent, Vector3 roomSize, float wallThickness, BuildingPalette palette, int layer = -1) { _parent = parent; _roomSize = roomSize; _wallThickness = wallThickness; _palette = palette; + _layer = layer; } #endregion @@ -171,18 +175,28 @@ public InteriorWallBuilder(Transform parent, Vector3 roomSize, float wallThickne string wallName = $"InteriorWall_{def.Axis}_{def.Position:F1}"; + GameObject? wall; + if (def.Opening == null || def.Opening.Type == WallOpeningType.None) { - return CreateSolidWall(wallName, center, size, wallColor, wallMaterial); + wall = CreateSolidWall(wallName, center, size, wallColor, wallMaterial); + } + else if (def.Opening.Type == WallOpeningType.Door) + { + wall = CreateWallWithDoor(wallName, center, size, def.Opening, isVertical, wallColor, wallMaterial); + } + else + { + // Fallback: solid wall for unsupported opening types + wall = CreateSolidWall(wallName, center, size, wallColor, wallMaterial); } - if (def.Opening.Type == WallOpeningType.Door) + if (wall != null && _layer >= 0) { - return CreateWallWithDoor(wallName, center, size, def.Opening, isVertical, wallColor, wallMaterial); + wall.SetLayerRecursively(_layer); } - // Fallback: solid wall for unsupported opening types - return CreateSolidWall(wallName, center, size, wallColor, wallMaterial); + return wall; } #endregion From be633a84a723b75ae0e7133e9d8c343dce9cd163 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Wed, 25 Feb 2026 15:08:12 -0500 Subject: [PATCH 20/64] feat(Building): add color and material parameters to AddFoundation and window frames --- Building/BuildingBuilder.cs | 6 +++-- Building/Structural/WallBuilder.cs | 40 ++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index c787d3d..685dc98 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -436,10 +436,12 @@ public BuildingBuilder AddCornerTrim(float width = 0.3f, float depth = 0.1f, Mat /// Foundation depth in meters /// Extra expansion on X axis /// Extra expansion on Z axis + /// Optional color override (defaults to grey) + /// Optional material override /// This builder for chaining - public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, float expandZ = 0f) + public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, float expandZ = 0f, Color? color = null, Material? material = null) { - GetDecorBuilder().AddFoundation(height, expandX, expandZ); + GetDecorBuilder().AddFoundation(height, expandX, expandZ, color, material); return this; } diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index 09f0e37..85649e0 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -75,6 +75,10 @@ public sealed class WallOpening /// Positive shifts toward the right/forward end, negative toward left/back. /// public float Offset { get; set; } = 0f; + /// Optional frame color override for window openings. Defaults to dark gray when null. + public Color? FrameColor { get; set; } + /// Optional frame material override for window openings. + public Material? FrameMaterial { get; set; } /// /// Creates a door opening configuration. @@ -102,11 +106,14 @@ public sealed class WallOpening /// Width of wall dividers between adjacent panes in meters (default 0.15). /// Optional glass material override. Defaults to Materials.WindowGlass when null. /// Lateral offset from center along the wall in meters (default 0, centered). + /// Optional frame material override. + /// Optional frame color override. Defaults to dark gray when null. /// A new WallOpening configured as a window. public static WallOpening Window( float width = 2.5f, float height = 2.0f, float sillHeight = 0.8f, int count = 1, float dividerWidth = Constants.Window.DefaultDividerWidth, - Material? glassMaterial = null, float offset = 0f) => new() + Material? glassMaterial = null, float offset = 0f, + Material? frameMaterial = null, Color? frameColor = null) => new() { Type = WallOpeningType.Window, Width = width, @@ -115,7 +122,9 @@ public static WallOpening Window( Count = count, DividerWidth = dividerWidth, GlassMaterial = glassMaterial, - Offset = offset + Offset = offset, + FrameMaterial = frameMaterial, + FrameColor = frameColor }; /// @@ -569,6 +578,7 @@ private void CreateWindowInSection( : windowWidth; float bandStart = -windowWidth / 2f + paneWidth / 2f; + Color frameColor = window.FrameColor ?? FrameColor; for (int i = 0; i < paneCount; i++) { @@ -581,7 +591,7 @@ private void CreateWindowInSection( // Frame for this pane string framePrefix = paneCount > 1 ? $"Frame{i}_" : "Frame"; - CreateWindowFrame(parent, paneCenter, paneWidth, windowHeight, windowCenterY, isVertical, framePrefix); + CreateWindowFrame(parent, paneCenter, paneWidth, windowHeight, windowCenterY, isVertical, frameColor, window.FrameMaterial, framePrefix); // Glass pane Vector3 glassSize = isVertical @@ -621,7 +631,7 @@ private void CreateWindowInSection( } } - private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windowWidth, float windowHeight, float windowCenterY, bool isVertical, string namePrefix = "Frame") + private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windowWidth, float windowHeight, float windowCenterY, bool isVertical, Color color, Material? material = null, string namePrefix = "Frame") { float frameDepth = Constants.Window.FrameDepth; float frameWidth = Constants.Window.FrameWidth; @@ -630,10 +640,10 @@ private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windo Vector3 topFrameSize = isVertical ? new Vector3(_wallThickness + frameDepth, frameWidth, windowWidth) : new Vector3(windowWidth, frameWidth, _wallThickness + frameDepth); - PrimitiveBuilder.CreateBox($"{namePrefix}Top", wallCenter + new Vector3(0f, windowCenterY + windowHeight / 2f - frameWidth / 2f, 0f), topFrameSize, FrameColor, parent); + GameObject top = PrimitiveBuilder.CreateBox($"{namePrefix}Top", wallCenter + new Vector3(0f, windowCenterY + windowHeight / 2f - frameWidth / 2f, 0f), topFrameSize, color, parent); // Bottom frame - PrimitiveBuilder.CreateBox($"{namePrefix}Bottom", wallCenter + new Vector3(0f, windowCenterY - windowHeight / 2f + frameWidth / 2f, 0f), topFrameSize, FrameColor, parent); + GameObject bottom = PrimitiveBuilder.CreateBox($"{namePrefix}Bottom", wallCenter + new Vector3(0f, windowCenterY - windowHeight / 2f + frameWidth / 2f, 0f), topFrameSize, color, parent); // Side frames Vector3 sideFrameSize = isVertical @@ -644,8 +654,16 @@ private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windo Vector3 leftFrameOffset = isVertical ? new Vector3(0f, windowCenterY, sideOffset) : new Vector3(-sideOffset, windowCenterY, 0f); Vector3 rightFrameOffset = isVertical ? new Vector3(0f, windowCenterY, -sideOffset) : new Vector3(sideOffset, windowCenterY, 0f); - PrimitiveBuilder.CreateBox($"{namePrefix}Left", wallCenter + leftFrameOffset, sideFrameSize, FrameColor, parent); - PrimitiveBuilder.CreateBox($"{namePrefix}Right", wallCenter + rightFrameOffset, sideFrameSize, FrameColor, parent); + GameObject left = PrimitiveBuilder.CreateBox($"{namePrefix}Left", wallCenter + leftFrameOffset, sideFrameSize, color, parent); + GameObject right = PrimitiveBuilder.CreateBox($"{namePrefix}Right", wallCenter + rightFrameOffset, sideFrameSize, color, parent); + + if (material != null) + { + ApplyFrameMaterial(top, material); + ApplyFrameMaterial(bottom, material); + ApplyFrameMaterial(left, material); + ApplyFrameMaterial(right, material); + } } private void ApplyWallMaterial(GameObject wall) @@ -657,6 +675,12 @@ private void ApplyWallMaterial(GameObject wall) } } + private void ApplyFrameMaterial(GameObject frame, Material material) + { + Renderer r = frame.GetComponent(); + if (r != null) r.material = material; + } + #endregion } } From e0b262caaecd48b833ec9fdb4164b388453aea96 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Thu, 26 Feb 2026 08:35:24 -0500 Subject: [PATCH 21/64] feat(Building): add NavMeshRepairer for runtime interior NavMesh --- Building/BuildingBuilder.cs | 95 +++++ Building/NavMeshRepairer.cs | 532 ++++++++++++++++++++++++++++ Building/Structural/DecorBuilder.cs | 2 +- Utils/Constants.cs | 78 ++++ 4 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 Building/NavMeshRepairer.cs diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 685dc98..de80186 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -45,6 +45,11 @@ public sealed class BuildingBuilder // Interior wall physics layer (-1 = default layer, no change) private int _interiorWallLayer = -1; + // Foundation and stair tracking for NavMesh link computation + private float _foundationHeight; + private readonly List<(WallSide Wall, float FoundationHeight, float Width, float Offset)> _stairs = + new List<(WallSide, float, float, float)>(); + // Stored wall openings for cross-builder communication (e.g., base molding gap) private WallOpening? _northOpening; private WallOpening? _southOpening; @@ -309,6 +314,27 @@ public BuildingBuilder AddInteriorWall( public IReadOnlyList InteriorDoorways => _interiorWallBuilder?.Doorways ?? (IReadOnlyList)System.Array.Empty(); + /// + /// Create a configured for this building. + /// Collects interior and exterior doorway positions, stair geometry, and building dimensions. + /// Call on the returned instance after positioning the building. + /// + /// NavMesh agent type to build for (0 = default agent) + /// A configured repairer ready to build + public NavMeshRepairer CreateNavMeshRepairer(int agentTypeID = 0) + { + var exteriorDoors = new List(); + + TryAddExteriorDoor(WallSide.North, _northOpening, exteriorDoors); + TryAddExteriorDoor(WallSide.South, _southOpening, exteriorDoors); + TryAddExteriorDoor(WallSide.East, _eastOpening, exteriorDoors); + TryAddExteriorDoor(WallSide.West, _westOpening, exteriorDoors); + + return new NavMeshRepairer( + _root.transform, _roomSize, + InteriorDoorways, exteriorDoors, agentTypeID, _foundationHeight); + } + #endregion #region Decoration @@ -441,6 +467,7 @@ public BuildingBuilder AddCornerTrim(float width = 0.3f, float depth = 0.1f, Mat /// This builder for chaining public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, float expandZ = 0f, Color? color = null, Material? material = null) { + _foundationHeight = height; GetDecorBuilder().AddFoundation(height, expandX, expandZ, color, material); return this; } @@ -475,6 +502,7 @@ public BuildingBuilder AddStairs( float gap = 0f) { float lateralOffset = GetDoorOffset(wall); + _stairs.Add((wall, foundationHeight, width, lateralOffset)); GetDecorBuilder().AddStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, style, flushWithFloor, gap, lateralOffset); return this; } @@ -717,6 +745,73 @@ private float GetDoorOffset(WallSide wall) #endregion + #region Private Methods - NavMesh + + /// + /// If is a door, compute its center, inward normal, + /// and optional stair base position, then append an to . + /// + private void TryAddExteriorDoor( + WallSide wall, WallOpening? opening, List list) + { + if (opening == null || opening.Type != WallOpeningType.Door) return; + + Vector3 center = wall switch + { + WallSide.North => new Vector3(_roomSize.x / 2f + opening.Offset, 0f, _roomSize.z), + WallSide.South => new Vector3(_roomSize.x / 2f + opening.Offset, 0f, 0f), + WallSide.East => new Vector3(_roomSize.x, 0f, _roomSize.z / 2f + opening.Offset), + WallSide.West => new Vector3(0f, 0f, _roomSize.z / 2f + opening.Offset), + _ => Vector3.zero + }; + + Vector3 inward = wall switch + { + WallSide.North => Vector3.back, + WallSide.South => Vector3.forward, + WallSide.East => Vector3.left, + WallSide.West => Vector3.right, + _ => Vector3.zero + }; + + Vector3? stairBase = ComputeStairBasePosition(wall, center, inward); + + list.Add(new ExteriorDoorwayInfo( + center, opening.Width, opening.Height, + inward, _config.WallThickness, stairBase)); + } + + /// + /// Compute the ground-level position at the base of the stairs for a given wall. + /// Returns null when no stairs exist on the wall or no foundation is present. + /// + private Vector3? ComputeStairBasePosition(WallSide wall, Vector3 doorCenter, Vector3 inwardNormal) + { + if (_foundationHeight <= 0f) return null; + + // Find stair entry for this wall + foreach ((WallSide stairWall, float foundationHeight, float width, float offset) in _stairs) + { + if (stairWall != wall) continue; + + // Compute stair run from foundation height using default step parameters + int stepCount = Mathf.Max(2, Mathf.CeilToInt(foundationHeight / Constants.Spatial.DefaultMaxStepHeight)); + int visibleSteps = stepCount - 1; + float clearance = Constants.Spatial.StairTopClearance; + float stairRun = visibleSteps * Constants.Spatial.DefaultStepDepth + + Constants.Spatial.DefaultStepDepth / 2f + clearance; + + Vector3 outward = -inwardNormal; + Vector3 stairBaseXZ = doorCenter + outward * stairRun; + + return new Vector3(stairBaseXZ.x, -foundationHeight, stairBaseXZ.z); + } + + return null; + } + + #endregion + #region Private Methods - Positioning private float GetOptimalMargin(FurnitureType type) diff --git a/Building/NavMeshRepairer.cs b/Building/NavMeshRepairer.cs new file mode 100644 index 0000000..0258783 --- /dev/null +++ b/Building/NavMeshRepairer.cs @@ -0,0 +1,532 @@ +using System.Collections.Generic; +using S1MAPI.Building.Structural; +using S1MAPI.Utils; +using UnityEngine; +using UnityEngine.AI; + +namespace S1MAPI.Building +{ + /// + /// Records the position and direction of an exterior doorway for NavMeshLink generation. + /// + public sealed class ExteriorDoorwayInfo + { + /// Center of the doorway in local building coordinates (Y=0, floor level). + public Vector3 Center { get; } + + /// Width of the doorway opening in meters. + public float Width { get; } + + /// Height of the doorway opening in meters. + public float Height { get; } + + /// Unit vector pointing from exterior toward interior (into the building). + public Vector3 InwardNormal { get; } + + /// Thickness of the wall containing this doorway. + public float WallThickness { get; } + + /// + /// Position at the base of the stairs in local building coordinates (ground level). + /// Null when no foundation/stairs — link is placed at the door instead. + /// + public Vector3? StairBasePosition { get; } + + /// + /// Create an exterior doorway info record. + /// + /// Door center in local building coordinates (Y=0) + /// Doorway width in meters + /// Doorway height in meters + /// Unit vector pointing into the building + /// Wall thickness in meters + /// Position at stair base (ground level), or null if no stairs + public ExteriorDoorwayInfo( + Vector3 center, float width, float height, + Vector3 inwardNormal, float wallThickness, + Vector3? stairBasePosition = null) + { + Center = center; + Width = width; + Height = height; + InwardNormal = inwardNormal; + WallThickness = wallThickness; + StairBasePosition = stairBasePosition; + } + } + + /// + /// Builds and manages runtime NavMesh for building interiors. + /// Collects building geometry (floor, walls, stairs) via physics colliders, + /// builds a walkable NavMesh surface, and creates NavMeshLinks at doorways + /// to connect interior rooms and bridge to the exterior NavMesh. + /// + /// + /// Call after the building is positioned in the scene. + /// Call when interior objects change (e.g. furniture moved). + /// Call when the building is destroyed. + /// + public sealed class NavMeshRepairer + { + #region Fields + + private readonly Transform _buildingRoot; + private readonly Vector3 _roomSize; + private readonly IReadOnlyList _interiorDoorways; + private readonly IReadOnlyList _exteriorDoorways; + private readonly int _agentTypeID; + private readonly float _foundationHeight; + + private NavMeshDataInstance _navInstance; + private readonly List _links = new List(); + private readonly List _rampObjects = new List(); + private GameObject? _obstacleGO; + private bool _isBuilt; + + #endregion + + #region Constructor + + /// + /// Create a new NavMesh repairer for a building. + /// + /// Root transform of the building (must be positioned before calling Build) + /// Interior room dimensions (width, height, depth) + /// Doorway positions from interior walls + /// Doorway positions from exterior walls + /// NavMesh agent type to build for (0 = default agent) + /// Foundation height in meters (0 = no foundation). Used to carve ground NavMesh. + public NavMeshRepairer( + Transform buildingRoot, + Vector3 roomSize, + IReadOnlyList interiorDoorways, + IReadOnlyList exteriorDoorways, + int agentTypeID = 0, + float foundationHeight = 0f) + { + _buildingRoot = buildingRoot; + _roomSize = roomSize; + _interiorDoorways = interiorDoorways; + _exteriorDoorways = exteriorDoorways; + _agentTypeID = agentTypeID; + _foundationHeight = foundationHeight; + } + + #endregion + + #region Properties + + /// + /// Whether the NavMesh is currently active. + /// + public bool IsBuilt => + _isBuilt; + + #endregion + + #region Public API + + /// + /// Build interior NavMesh and create doorway links. + /// Must be called after the building is positioned in the scene. + /// + public void Build() + { + if (_isBuilt) + { + DebugLog.Warning("[NavMeshRepairer] NavMesh already built. Call Rebuild() to refresh."); + return; + } + + // 1. Create invisible ramp colliders so the NavMesh has continuous walkable + // surface from floor level to ground level at each staircase. + // Individual stair steps are too narrow for the agent radius, so without + // a ramp the only path is a single-point NavMeshLink (bottleneck). + CreateStairRamps(); + + // 2. Collect physics colliders from the building hierarchy as NavMesh sources. +#if MONO + // On Mono we can use Unity's built-in CollectSources which handles + // all coordinate math, collider types, and source construction correctly. + // Exclude stair step colliders — their individual treads are too narrow + // for the agent radius and block walkable surface on the ramp beneath them. + var sources = new List(); + var markups = new List(); + foreach (Transform child in _buildingRoot) + { + if (child.name == Constants.Spatial.StairsFolderName) + { + markups.Add(new NavMeshBuildMarkup { root = child, ignoreFromBuild = true }); + } + } + NavMeshBuilder.CollectSources( + _buildingRoot, ~0, NavMeshCollectGeometry.PhysicsColliders, + 0, markups, sources); +#elif IL2CPP + // On IL2CPP, CollectSources(Transform) has type compatibility issues + // with Il2CppSystem.Collections.Generic.List, so we collect manually. + List sources = CollectBuildingSources(); +#endif + + if (sources.Count == 0) + { + DebugLog.Warning("[NavMeshRepairer] No colliders found in building hierarchy."); + return; + } + + // 3. Compute bounds from collected sources + Bounds localBounds = ComputeBoundsFromSources(sources); + + // 4. Build NavMesh data + NavMeshBuildSettings settings = NavMesh.GetSettingsByID(_agentTypeID); + + // Pad bounds to avoid clipping walkable surfaces at edges + localBounds.Expand(Constants.NavMesh.BoundsExpansion); + +#if IL2CPP + Il2CppSystem.Collections.Generic.List il2CppSources = sources.ToIl2CppList(); + NavMeshData navData = NavMeshBuilder.BuildNavMeshData( + settings, + il2CppSources, + localBounds, + _buildingRoot.position, + _buildingRoot.rotation); +#elif MONO + NavMeshData navData = NavMeshBuilder.BuildNavMeshData( + settings, + sources, + localBounds, + _buildingRoot.position, + _buildingRoot.rotation); +#endif + + if (navData == null) + { + DebugLog.Error("[NavMeshRepairer] NavMeshBuilder.BuildNavMeshData returned null."); + return; + } + + // 5. Register with NavMesh system and verify triangles were added +#if MONO + int trisBefore = NavMesh.CalculateTriangulation().indices.Length / 3; +#endif + _navInstance = NavMesh.AddNavMeshData(navData); +#if MONO + int trisAfter = NavMesh.CalculateTriangulation().indices.Length / 3; + int trisDelta = trisAfter - trisBefore; + if (trisDelta > 0) + DebugLog.Info($"[NavMeshRepairer] NavMesh added {trisDelta} triangles (total: {trisAfter})."); + else + DebugLog.Warning($"[NavMeshRepairer] NavMesh added ZERO triangles! " + + $"Runtime mesh is empty — interior pathing impossible. " + + $"(before={trisBefore}, after={trisAfter})"); +#endif + + // 6. Verify the runtime NavMesh is queryable at room center + Vector3 testWorld = _buildingRoot.TransformPoint( + new Vector3(_roomSize.x / 2f, Constants.NavMesh.VerifyTestYOffset, _roomSize.z / 2f)); + NavMeshHit hit; + bool found = NavMesh.SamplePosition(testWorld, out hit, Constants.NavMesh.VerifySampleRadius, NavMesh.AllAreas); + if (!found) + { + DebugLog.Warning("[NavMeshRepairer] No NavMesh surface found at room center. " + + "Interior pathing will not work."); + } + else if (Mathf.Abs(hit.position.y - testWorld.y) >= Constants.NavMesh.VerifyMaxYDiff) + { + DebugLog.Warning($"[NavMeshRepairer] NavMesh surface at {hit.position} may be the game's " + + $"ground mesh, not our interior surface (expected Y≈{testWorld.y:F2})."); + } + + // 7. Carve the game's ground-level NavMesh under the building so NPCs + // cannot path through the foundation and must use stair links instead. + if (_foundationHeight > 0f) + { + CreateGroundObstacle(); + } + + // 8. Create doorway links + CreateInteriorDoorwayLinks(); + CreateExteriorDoorwayLinks(); + + _isBuilt = true; + + DebugLog.Info($"[NavMeshRepairer] Built NavMesh: {sources.Count} sources, " + + $"{_interiorDoorways.Count} interior links, {_exteriorDoorways.Count} exterior links"); + } + + /// + /// Tear down and rebuild the NavMesh. + /// Use when interior objects change (e.g. furniture placed or moved). + /// + public void Rebuild() + { + Remove(); + Build(); + } + + /// + /// Remove all NavMesh data and links. + /// Call when the building is destroyed. + /// + public void Remove() + { + if (!_isBuilt) return; + + _navInstance.Remove(); + + foreach (NavMeshLinkInstance link in _links) + { + link.Remove(); + } + _links.Clear(); + + foreach (GameObject ramp in _rampObjects) + { + UnityEngine.Object.Destroy(ramp); + } + _rampObjects.Clear(); + + if (_obstacleGO != null) + { + UnityEngine.Object.Destroy(_obstacleGO); + _obstacleGO = null; + } + + _isBuilt = false; + + DebugLog.Info("[NavMeshRepairer] Removed NavMesh data and links."); + } + + #endregion + + #region Private Methods — Ground Carving + + /// + /// Create a NavMeshObstacle that carves the game's baked ground-level NavMesh + /// under the building footprint. Without this, NPCs path on the ground mesh + /// straight through the foundation instead of using the stair links. + /// The obstacle is positioned at ground level and sized to the room footprint. + /// Stairs extend outward beyond the footprint and are not affected. + /// + private void CreateGroundObstacle() + { + _obstacleGO = new GameObject("NavMeshObstacle"); + _obstacleGO.transform.SetParent(_buildingRoot); + _obstacleGO.transform.localPosition = new Vector3( + _roomSize.x / 2f, -_foundationHeight, _roomSize.z / 2f); + + var obstacle = _obstacleGO.AddComponent(); + obstacle.shape = NavMeshObstacleShape.Box; + obstacle.center = Vector3.zero; + obstacle.size = new Vector3( + _roomSize.x + Constants.NavMesh.ObstacleExpand, + Constants.NavMesh.ObstacleHeight, + _roomSize.z + Constants.NavMesh.ObstacleExpand); + obstacle.carving = true; + } + + #endregion + + #region Private Methods — Stair Ramps + + /// + /// Create invisible ramp colliders at each exterior doorway with stairs. + /// The ramp provides continuous walkable NavMesh from floor level down to + /// ground level, so multiple NPCs can walk the slope simultaneously instead + /// of queuing at a single NavMeshLink portal. + /// + private void CreateStairRamps() + { + foreach (ExteriorDoorwayInfo doorway in _exteriorDoorways) + { + if (!doorway.StairBasePosition.HasValue) continue; + + Vector3 top = doorway.Center; // (x, 0, z) at floor level + Vector3 bottom = doorway.StairBasePosition.Value; // (x, -fh, z) at ground level + + // Ramp geometry + Vector3 flatDelta = new Vector3(bottom.x - top.x, 0f, bottom.z - top.z); + float horizontalDist = flatDelta.magnitude; + float verticalDist = Mathf.Abs(bottom.y); // = foundationHeight + float rampLength = Mathf.Sqrt(horizontalDist * horizontalDist + verticalDist * verticalDist); + float slopeAngle = Mathf.Atan2(verticalDist, horizontalDist) * Mathf.Rad2Deg; + + Vector3 mid = (top + bottom) / 2f; + Vector3 outward = flatDelta.normalized; // flat direction from door to stair base + + // Create invisible ramp collider parented to building root. + // CollectSources will pick it up automatically. + GameObject rampGO = new GameObject("NavMeshRamp"); + rampGO.transform.SetParent(_buildingRoot); + rampGO.transform.localPosition = mid; + + // Face outward, then tilt down by slope angle around local X (right axis) + Quaternion facing = Quaternion.LookRotation(outward, Vector3.up); + rampGO.transform.localRotation = facing * Quaternion.AngleAxis(slopeAngle, Vector3.right); + + // Width must exceed the NavMeshLink width (3m) plus agent-radius + // erosion on both edges, otherwise the walkable strip is so narrow + // that NPCs path to the corner instead of walking up the center. + float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinLinkWidth) + Constants.NavMesh.RampErosionBuffer; + + BoxCollider col = rampGO.AddComponent(); + col.center = Vector3.zero; + col.size = new Vector3(rampWidth, Constants.NavMesh.RampColliderThickness, rampLength); + + _rampObjects.Add(rampGO); + } + } + + #endregion + + #region Private Methods — Source Collection + + /// + /// Scan the building hierarchy for BoxColliders and create NavMeshBuildSources. + /// Sources are in local building coordinates so BuildNavMeshData can transform + /// them using the building's world position and rotation. + /// + private List CollectBuildingSources() + { + var sources = new List(); + BoxCollider[] colliders = _buildingRoot.GetComponentsInChildren(); + + foreach (BoxCollider collider in colliders) + { + // Compute world-space center, then convert to local building space + Vector3 worldCenter = collider.transform.TransformPoint(collider.center); + Vector3 localCenter = _buildingRoot.InverseTransformPoint(worldCenter); + + // Compute local rotation relative to building root + Quaternion localRot = Quaternion.Inverse(_buildingRoot.rotation) * collider.transform.rotation; + + // Actual box dimensions = collider size * transform scale + Vector3 worldSize = Vector3.Scale(collider.size, collider.transform.lossyScale); + + sources.Add(new NavMeshBuildSource + { + shape = NavMeshBuildSourceShape.Box, + size = worldSize, + transform = Matrix4x4.TRS(localCenter, localRot, Vector3.one), + area = 0 + }); + } + + return sources; + } + + /// + /// Compute an encapsulating bounds from collected NavMesh build sources. + /// + private Bounds ComputeBoundsFromSources(List sources) + { + // Start with a reasonable default based on room size + var bounds = new Bounds( + new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, _roomSize.z / 2f), + _roomSize); + + foreach (NavMeshBuildSource source in sources) + { + // Extract position from the source transform matrix + Vector3 sourcePos = new Vector3( + source.transform.m03, + source.transform.m13, + source.transform.m23); + + // Expand bounds to include source position + half size + var sourceBounds = new Bounds(sourcePos, source.size); + bounds.Encapsulate(sourceBounds); + } + + return bounds; + } + + #endregion + + #region Private Methods — Interior Links + + /// + /// Create NavMeshLinks at each interior doorway to connect rooms through walls. + /// + private void CreateInteriorDoorwayLinks() + { + foreach (DoorwayInfo doorway in _interiorDoorways) + { + Vector3 facingDir = doorway.FacesAlongZ ? Vector3.forward : Vector3.right; + float halfWall = doorway.WallThickness / 2f + Constants.NavMesh.LinkOffset; + + var linkData = new NavMeshLinkData(); + linkData.startPosition = doorway.Center - facingDir * halfWall; + linkData.endPosition = doorway.Center + facingDir * halfWall; + linkData.width = doorway.Width; + linkData.bidirectional = true; + linkData.area = 0; + linkData.agentTypeID = _agentTypeID; + linkData.costModifier = Constants.NavMesh.DefaultLinkCostModifier; + + NavMeshLinkInstance link = NavMesh.AddLink( + linkData, + _buildingRoot.position, + _buildingRoot.rotation); + + _links.Add(link); + } + } + + #endregion + + #region Private Methods — Exterior Links + + /// + /// Create NavMeshLinks at each exterior doorway to connect interior to exterior NavMesh. + /// When stairs exist, the link is placed at the stair base (ground level). + /// Without stairs, the link bridges directly through the wall. + /// + private void CreateExteriorDoorwayLinks() + { + foreach (ExteriorDoorwayInfo doorway in _exteriorDoorways) + { + float halfWall = doorway.WallThickness / 2f + Constants.NavMesh.LinkOffset; + + Vector3 interiorEnd; + Vector3 exteriorEnd; + + if (doorway.StairBasePosition.HasValue) + { + // Interior endpoint at floor level (inside the door) — on our runtime NavMesh. + // Exterior endpoint at ground level (outside the stair base) — on the game's NavMesh. + // The invisible ramp provides walkable surface between floor and ground for + // NPCs already on our NavMesh; the link handles the cross-mesh bridge. + interiorEnd = doorway.Center + doorway.InwardNormal * halfWall; + exteriorEnd = doorway.StairBasePosition.Value + - doorway.InwardNormal * Constants.NavMesh.LinkOffset; + } + else + { + // No foundation: link directly through the wall at floor level + interiorEnd = doorway.Center + doorway.InwardNormal * halfWall; + exteriorEnd = doorway.Center - doorway.InwardNormal * halfWall; + } + + var linkData = new NavMeshLinkData(); + linkData.startPosition = interiorEnd; + linkData.endPosition = exteriorEnd; + linkData.width = Mathf.Max(doorway.Width, Constants.NavMesh.MinLinkWidth); + linkData.bidirectional = true; + linkData.area = 0; + linkData.agentTypeID = _agentTypeID; + linkData.costModifier = Constants.NavMesh.DefaultLinkCostModifier; + + NavMeshLinkInstance link = NavMesh.AddLink( + linkData, + _buildingRoot.position, + _buildingRoot.rotation); + + _links.Add(link); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index 51a7c4f..9e3bd7c 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -527,7 +527,7 @@ private GameObject AddSolidStairs( bool flushWithFloor = false, float lateralOffset = 0f) { - GameObject container = BuildingUtilities.CreateFolder("Stairs", _parent); + GameObject container = BuildingUtilities.CreateFolder(Constants.Spatial.StairsFolderName, _parent); Color stepColor = color ?? _palette.FloorColor; int count = Mathf.Max(2, Mathf.CeilToInt(foundationHeight / maxStepHeight)); float stepRise = foundationHeight / count; diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 6d8bb74..a1dda55 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -139,6 +139,18 @@ public static class Spatial /// Default step depth (tread) for generated stairs in meters. /// public const float DefaultStepDepth = 0.3f; + + /// + /// Clearance gap between the top stair step and the foundation edge in meters. + /// + public const float StairTopClearance = 0.2f; + + /// + /// GameObject folder name for stair geometry under the building root. + /// Used by DecorBuilder to parent step colliders and by NavMeshRepairer + /// to exclude them from NavMesh source collection. + /// + public const string StairsFolderName = "Stairs"; } /// @@ -280,6 +292,72 @@ public static class Roof public const float DefaultRoofColorB = 0.3f; } + /// + /// NavMesh repair and link constants. + /// + public static class NavMesh + { + /// + /// Offset from doorway center to NavMeshLink endpoint, in meters. + /// Must be large enough to land on the walkable surface on each side of the wall. + /// + public const float LinkOffset = 0.3f; + + /// + /// Minimum width for exterior NavMeshLinks in meters. + /// Wider links allow more NPCs to traverse simultaneously. + /// + public const float MinLinkWidth = 3.0f; + + /// + /// Extra width added to stair ramps beyond the link width to compensate + /// for NavMesh agent-radius erosion on both edges. + /// + public const float RampErosionBuffer = 1.0f; + + /// + /// Thickness of invisible ramp colliders in meters. + /// Thin enough to not interfere with gameplay, thick enough for NavMesh voxelization. + /// + public const float RampColliderThickness = 0.1f; + + /// + /// Padding added to NavMesh build bounds to avoid clipping walkable surfaces at edges. + /// + public const float BoundsExpansion = 1.0f; + + /// + /// Radius for NavMesh.SamplePosition verification queries in meters. + /// + public const float VerifySampleRadius = 0.5f; + + /// + /// Maximum acceptable Y-distance between expected and actual NavMesh surface + /// during verification, in meters. + /// + public const float VerifyMaxYDiff = 0.5f; + + /// + /// Y-offset above floor level for the NavMesh verification test point. + /// + public const float VerifyTestYOffset = 0.1f; + + /// + /// Padding added to the ground obstacle beyond the room footprint in meters. + /// + public const float ObstacleExpand = 0.5f; + + /// + /// Height of the ground-carving NavMeshObstacle in meters. + /// + public const float ObstacleHeight = 0.5f; + + /// + /// Default cost modifier for NavMeshLinks. -1 uses the NavMesh area cost. + /// + public const float DefaultLinkCostModifier = -1f; + } + /// /// Interior wall geometry constants. /// From 227f0e10af352547db5ad7e7d71c182ce8fd8bb2 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Feb 2026 19:13:41 -0500 Subject: [PATCH 22/64] fix(Building): fix NavMesh doorway filter and add interior doorway support --- Building/BuildingBuilder.cs | 30 +- Building/NavMeshRepairer.cs | 468 +++++++++++++-------- Building/Structural/InteriorWallBuilder.cs | 1 - Utils/Constants.cs | 40 +- 4 files changed, 335 insertions(+), 204 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index de80186..1de8f1e 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -316,23 +316,33 @@ public BuildingBuilder AddInteriorWall( /// /// Create a configured for this building. - /// Collects interior and exterior doorway positions, stair geometry, and building dimensions. + /// Collects exterior and interior doorway positions, stair geometry, and building dimensions. /// Call on the returned instance after positioning the building. /// /// NavMesh agent type to build for (0 = default agent) /// A configured repairer ready to build public NavMeshRepairer CreateNavMeshRepairer(int agentTypeID = 0) { - var exteriorDoors = new List(); + var doorways = new List(); - TryAddExteriorDoor(WallSide.North, _northOpening, exteriorDoors); - TryAddExteriorDoor(WallSide.South, _southOpening, exteriorDoors); - TryAddExteriorDoor(WallSide.East, _eastOpening, exteriorDoors); - TryAddExteriorDoor(WallSide.West, _westOpening, exteriorDoors); + // Exterior doorways (with optional stair base positions) + TryAddExteriorDoor(WallSide.North, _northOpening, doorways); + TryAddExteriorDoor(WallSide.South, _southOpening, doorways); + TryAddExteriorDoor(WallSide.East, _eastOpening, doorways); + TryAddExteriorDoor(WallSide.West, _westOpening, doorways); + + // Interior doorways (for door panel collider filtering only) + foreach (DoorwayInfo interior in InteriorDoorways) + { + Vector3 normal = interior.FacesAlongZ ? Vector3.forward : Vector3.right; + doorways.Add(new NavMeshDoorwayInfo( + interior.Center, interior.Width, interior.Height, + normal, interior.WallThickness)); + } return new NavMeshRepairer( _root.transform, _roomSize, - InteriorDoorways, exteriorDoors, agentTypeID, _foundationHeight); + doorways, agentTypeID, _foundationHeight); } #endregion @@ -749,10 +759,10 @@ private float GetDoorOffset(WallSide wall) /// /// If is a door, compute its center, inward normal, - /// and optional stair base position, then append an to . + /// and optional stair base position, then append a to . /// private void TryAddExteriorDoor( - WallSide wall, WallOpening? opening, List list) + WallSide wall, WallOpening? opening, List list) { if (opening == null || opening.Type != WallOpeningType.Door) return; @@ -776,7 +786,7 @@ private void TryAddExteriorDoor( Vector3? stairBase = ComputeStairBasePosition(wall, center, inward); - list.Add(new ExteriorDoorwayInfo( + list.Add(new NavMeshDoorwayInfo( center, opening.Width, opening.Height, inward, _config.WallThickness, stairBase)); } diff --git a/Building/NavMeshRepairer.cs b/Building/NavMeshRepairer.cs index 0258783..a178091 100644 --- a/Building/NavMeshRepairer.cs +++ b/Building/NavMeshRepairer.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using S1MAPI.Building.Structural; using S1MAPI.Utils; using UnityEngine; using UnityEngine.AI; @@ -7,9 +6,10 @@ namespace S1MAPI.Building { /// - /// Records the position and direction of an exterior doorway for NavMeshLink generation. + /// Records the position and dimensions of a doorway for NavMesh source filtering + /// and ramp/ground plane generation. Used for both exterior and interior doorways. /// - public sealed class ExteriorDoorwayInfo + public sealed class NavMeshDoorwayInfo { /// Center of the doorway in local building coordinates (Y=0, floor level). public Vector3 Center { get; } @@ -20,7 +20,7 @@ public sealed class ExteriorDoorwayInfo /// Height of the doorway opening in meters. public float Height { get; } - /// Unit vector pointing from exterior toward interior (into the building). + /// Unit vector perpendicular to the wall face in the XZ plane. public Vector3 InwardNormal { get; } /// Thickness of the wall containing this doorway. @@ -28,20 +28,20 @@ public sealed class ExteriorDoorwayInfo /// /// Position at the base of the stairs in local building coordinates (ground level). - /// Null when no foundation/stairs — link is placed at the door instead. + /// Null for interior doorways or exterior doorways without stairs. /// public Vector3? StairBasePosition { get; } /// - /// Create an exterior doorway info record. + /// Create a NavMesh doorway info record. /// /// Door center in local building coordinates (Y=0) /// Doorway width in meters /// Doorway height in meters - /// Unit vector pointing into the building + /// Unit vector perpendicular to the wall face /// Wall thickness in meters /// Position at stair base (ground level), or null if no stairs - public ExteriorDoorwayInfo( + public NavMeshDoorwayInfo( Vector3 center, float width, float height, Vector3 inwardNormal, float wallThickness, Vector3? stairBasePosition = null) @@ -57,9 +57,11 @@ public ExteriorDoorwayInfo( /// /// Builds and manages runtime NavMesh for building interiors. - /// Collects building geometry (floor, walls, stairs) via physics colliders, - /// builds a walkable NavMesh surface, and creates NavMeshLinks at doorways - /// to connect interior rooms and bridge to the exterior NavMesh. + /// Collects building geometry (floor, walls, ramps) from the building hierarchy, + /// adds manual ground planes at stair bases, and builds a single connected NavMesh + /// from the ground patch through the ramp into the interior. + /// A carving obstacle removes the game's baked mesh in the covered area so NPCs + /// pathfind exclusively on the runtime surface within the building zone. /// /// /// Call after the building is positioned in the scene. @@ -72,13 +74,11 @@ public sealed class NavMeshRepairer private readonly Transform _buildingRoot; private readonly Vector3 _roomSize; - private readonly IReadOnlyList _interiorDoorways; - private readonly IReadOnlyList _exteriorDoorways; + private readonly IReadOnlyList _doorways; private readonly int _agentTypeID; private readonly float _foundationHeight; private NavMeshDataInstance _navInstance; - private readonly List _links = new List(); private readonly List _rampObjects = new List(); private GameObject? _obstacleGO; private bool _isBuilt; @@ -92,22 +92,19 @@ public sealed class NavMeshRepairer /// /// Root transform of the building (must be positioned before calling Build) /// Interior room dimensions (width, height, depth) - /// Doorway positions from interior walls - /// Doorway positions from exterior walls + /// Doorway positions for source filtering and ramp generation /// NavMesh agent type to build for (0 = default agent) /// Foundation height in meters (0 = no foundation). Used to carve ground NavMesh. public NavMeshRepairer( Transform buildingRoot, Vector3 roomSize, - IReadOnlyList interiorDoorways, - IReadOnlyList exteriorDoorways, + IReadOnlyList doorways, int agentTypeID = 0, float foundationHeight = 0f) { _buildingRoot = buildingRoot; _roomSize = roomSize; - _interiorDoorways = interiorDoorways; - _exteriorDoorways = exteriorDoorways; + _doorways = doorways; _agentTypeID = agentTypeID; _foundationHeight = foundationHeight; } @@ -127,8 +124,9 @@ public NavMeshRepairer( #region Public API /// - /// Build interior NavMesh and create doorway links. - /// Must be called after the building is positioned in the scene. + /// Build a NavMesh covering the building interior, stair ramps, and ground patches + /// at each stair base. A carving obstacle removes the game's baked mesh in the + /// covered area. Must be called after the building is positioned in the scene. /// public void Build() { @@ -138,23 +136,31 @@ public void Build() return; } - // 1. Create invisible ramp colliders so the NavMesh has continuous walkable + // 1. Create carving obstacle covering ONLY the building footprint. + // Does NOT extend to stair approach area — the baked mesh must persist + // there so it overlaps with our runtime ground planes, giving NPCs a + // cross-instance transition point. + // Created early to give Unity maximum time for async carving. + if (_foundationHeight > 0f) + { + CreateGroundObstacle(); + } + + Bounds contentBounds = ComputeLocalBounds(); + + // 2. Create invisible ramp colliders so the NavMesh has continuous walkable // surface from floor level to ground level at each staircase. - // Individual stair steps are too narrow for the agent radius, so without - // a ramp the only path is a single-point NavMeshLink (bottleneck). CreateStairRamps(); - // 2. Collect physics colliders from the building hierarchy as NavMesh sources. + // 3. Collect physics colliders from the building hierarchy as NavMesh sources. + // Sources are in local building coordinates. #if MONO - // On Mono we can use Unity's built-in CollectSources which handles - // all coordinate math, collider types, and source construction correctly. - // Exclude stair step colliders — their individual treads are too narrow - // for the agent radius and block walkable surface on the ramp beneath them. var sources = new List(); var markups = new List(); foreach (Transform child in _buildingRoot) { - if (child.name == Constants.Spatial.StairsFolderName) + if (child.name == Constants.Spatial.StairsFolderName || + child.name == Constants.Spatial.FoundationFolderName) { markups.Add(new NavMeshBuildMarkup { root = child, ignoreFromBuild = true }); } @@ -163,39 +169,82 @@ public void Build() _buildingRoot, ~0, NavMeshCollectGeometry.PhysicsColliders, 0, markups, sources); #elif IL2CPP - // On IL2CPP, CollectSources(Transform) has type compatibility issues - // with Il2CppSystem.Collections.Generic.List, so we collect manually. List sources = CollectBuildingSources(); #endif + // 4. Remove colliders that sit inside door openings (e.g. door panels/prefabs). + // Must run before adding manual sources so those aren't filtered out. + FilterDoorwaySources(sources); + + // 5. Add flat ground plane sources at each stair base so the runtime mesh + // has walkable surface at ground level connecting the ramp to the baked mesh. + AddGroundPlanes(sources); + + // 6. Add threshold sources at each doorway to bridge the wall-thickness gap + // between the ramp top (outer wall face) and the floor (inner wall face). + AddDoorwayThresholds(sources); + + // 7. Add a "Not Walkable" blocker at ground level covering the building interior. + // Area 1 (Not Walkable) takes absolute precedence during voxelization — + // prevents the runtime mesh from creating a walkable phantom surface at + // ground level inside the building. The floor at Y=0 is unaffected. + if (_foundationHeight > 0f) + { + sources.Add(new NavMeshBuildSource + { + shape = NavMeshBuildSourceShape.Box, + size = new Vector3(_roomSize.x, 0.5f, _roomSize.z), + transform = Matrix4x4.TRS( + new Vector3(_roomSize.x / 2f, -_foundationHeight, _roomSize.z / 2f), + Quaternion.identity, + Vector3.one), + area = 1 // Not Walkable + }); + } + if (sources.Count == 0) { DebugLog.Warning("[NavMeshRepairer] No colliders found in building hierarchy."); return; } - // 3. Compute bounds from collected sources - Bounds localBounds = ComputeBoundsFromSources(sources); + // 6. Compute bake bounds = content + expansion. + // The expansion zone extends past the obstacle, creating an overlap + // where both runtime and baked meshes coexist for smooth NPC transition. + Bounds bakeBounds = contentBounds; + bakeBounds.Expand(Constants.NavMesh.BoundsExpansion); - // 4. Build NavMesh data + // 7. Build NavMesh data — local sources positioned by building transform NavMeshBuildSettings settings = NavMesh.GetSettingsByID(_agentTypeID); - // Pad bounds to avoid clipping walkable surfaces at edges - localBounds.Expand(Constants.NavMesh.BoundsExpansion); + // Override voxel size to satisfy the 4-voxel rule for vertical separation. + // foundationHeight / voxelSize must be >= 4 to prevent surface merging. + // Use 6 voxels for safety margin; only override if needed. + if (_foundationHeight > 0f) + { + float maxVoxel = _foundationHeight / 6f; + if (settings.voxelSize > maxVoxel) + { + settings.overrideVoxelSize = true; + settings.voxelSize = maxVoxel; + DebugLog.Info($"[NavMeshRepairer] Overrode voxelSize to {maxVoxel:F3} " + + $"(foundation={_foundationHeight:F2}, voxels={_foundationHeight / maxVoxel:F1})"); + } + } #if IL2CPP Il2CppSystem.Collections.Generic.List il2CppSources = sources.ToIl2CppList(); NavMeshData navData = NavMeshBuilder.BuildNavMeshData( settings, il2CppSources, - localBounds, + bakeBounds, _buildingRoot.position, _buildingRoot.rotation); #elif MONO NavMeshData navData = NavMeshBuilder.BuildNavMeshData( settings, sources, - localBounds, + bakeBounds, _buildingRoot.position, _buildingRoot.rotation); #endif @@ -206,7 +255,7 @@ public void Build() return; } - // 5. Register with NavMesh system and verify triangles were added + // 8. Register with NavMesh system and verify triangles were added #if MONO int trisBefore = NavMesh.CalculateTriangulation().indices.Length / 3; #endif @@ -222,11 +271,12 @@ public void Build() $"(before={trisBefore}, after={trisAfter})"); #endif - // 6. Verify the runtime NavMesh is queryable at room center + // 9. Verify the runtime NavMesh is queryable at room center Vector3 testWorld = _buildingRoot.TransformPoint( new Vector3(_roomSize.x / 2f, Constants.NavMesh.VerifyTestYOffset, _roomSize.z / 2f)); NavMeshHit hit; bool found = NavMesh.SamplePosition(testWorld, out hit, Constants.NavMesh.VerifySampleRadius, NavMesh.AllAreas); + if (!found) { DebugLog.Warning("[NavMeshRepairer] No NavMesh surface found at room center. " + @@ -238,21 +288,10 @@ public void Build() $"ground mesh, not our interior surface (expected Y≈{testWorld.y:F2})."); } - // 7. Carve the game's ground-level NavMesh under the building so NPCs - // cannot path through the foundation and must use stair links instead. - if (_foundationHeight > 0f) - { - CreateGroundObstacle(); - } - - // 8. Create doorway links - CreateInteriorDoorwayLinks(); - CreateExteriorDoorwayLinks(); - _isBuilt = true; DebugLog.Info($"[NavMeshRepairer] Built NavMesh: {sources.Count} sources, " + - $"{_interiorDoorways.Count} interior links, {_exteriorDoorways.Count} exterior links"); + $"bake bounds size={bakeBounds.size}"); } /// @@ -266,7 +305,7 @@ public void Rebuild() } /// - /// Remove all NavMesh data and links. + /// Remove all NavMesh data and cleanup. /// Call when the building is destroyed. /// public void Remove() @@ -275,12 +314,6 @@ public void Remove() _navInstance.Remove(); - foreach (NavMeshLinkInstance link in _links) - { - link.Remove(); - } - _links.Clear(); - foreach (GameObject ramp in _rampObjects) { UnityEngine.Object.Destroy(ramp); @@ -295,7 +328,45 @@ public void Remove() _isBuilt = false; - DebugLog.Info("[NavMeshRepairer] Removed NavMesh data and links."); + DebugLog.Info("[NavMeshRepairer] Removed NavMesh data."); + } + + #endregion + + #region Private Methods — Bounds + + /// + /// Compute local-space bounds covering the building footprint, foundation depth, + /// and ground patches at each stair base. + /// + private Bounds ComputeLocalBounds() + { + // Start with building footprint + var bounds = new Bounds( + new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, _roomSize.z / 2f), + _roomSize); + + // Extend down to ground level + if (_foundationHeight > 0f) + { + bounds.Encapsulate(new Vector3(_roomSize.x / 2f, -_foundationHeight, _roomSize.z / 2f)); + } + + // Extend to cover ground patches past each stair base + float extension = Constants.NavMesh.GroundPatchExtension; + foreach (NavMeshDoorwayInfo doorway in _doorways) + { + if (!doorway.StairBasePosition.HasValue) continue; + + Vector3 stairBase = doorway.StairBasePosition.Value; + Vector3 outward = -doorway.InwardNormal; + Vector3 patchEnd = stairBase + outward * extension; + + bounds.Encapsulate(stairBase); + bounds.Encapsulate(patchEnd); + } + + return bounds; } #endregion @@ -303,11 +374,11 @@ public void Remove() #region Private Methods — Ground Carving /// - /// Create a NavMeshObstacle that carves the game's baked ground-level NavMesh - /// under the building footprint. Without this, NPCs path on the ground mesh - /// straight through the foundation instead of using the stair links. - /// The obstacle is positioned at ground level and sized to the room footprint. - /// Stairs extend outward beyond the footprint and are not affected. + /// Create a NavMeshObstacle that carves the game's baked NavMesh under the building + /// footprint ONLY. Does not extend to the stair approach area — the baked mesh must + /// persist there to overlap with the runtime ground planes, providing the cross-instance + /// transition point where NPCs step from the baked terrain onto the runtime surface. + /// Created early in to give Unity maximum time for async carving. /// private void CreateGroundObstacle() { @@ -316,14 +387,18 @@ private void CreateGroundObstacle() _obstacleGO.transform.localPosition = new Vector3( _roomSize.x / 2f, -_foundationHeight, _roomSize.z / 2f); + Vector3 obstacleSize = new Vector3( + _roomSize.x, + Constants.NavMesh.ObstacleHeight, + _roomSize.z); + var obstacle = _obstacleGO.AddComponent(); obstacle.shape = NavMeshObstacleShape.Box; obstacle.center = Vector3.zero; - obstacle.size = new Vector3( - _roomSize.x + Constants.NavMesh.ObstacleExpand, - Constants.NavMesh.ObstacleHeight, - _roomSize.z + Constants.NavMesh.ObstacleExpand); + obstacle.size = obstacleSize; obstacle.carving = true; + obstacle.carvingTimeToStationary = 0f; + } #endregion @@ -333,12 +408,11 @@ private void CreateGroundObstacle() /// /// Create invisible ramp colliders at each exterior doorway with stairs. /// The ramp provides continuous walkable NavMesh from floor level down to - /// ground level, so multiple NPCs can walk the slope simultaneously instead - /// of queuing at a single NavMeshLink portal. + /// ground level, so multiple NPCs can walk the slope simultaneously. /// private void CreateStairRamps() { - foreach (ExteriorDoorwayInfo doorway in _exteriorDoorways) + foreach (NavMeshDoorwayInfo doorway in _doorways) { if (!doorway.StairBasePosition.HasValue) continue; @@ -365,10 +439,10 @@ private void CreateStairRamps() Quaternion facing = Quaternion.LookRotation(outward, Vector3.up); rampGO.transform.localRotation = facing * Quaternion.AngleAxis(slopeAngle, Vector3.right); - // Width must exceed the NavMeshLink width (3m) plus agent-radius + // Width must exceed the minimum ramp width plus agent-radius // erosion on both edges, otherwise the walkable strip is so narrow // that NPCs path to the corner instead of walking up the center. - float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinLinkWidth) + Constants.NavMesh.RampErosionBuffer; + float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinRampWidth) + Constants.NavMesh.RampErosionBuffer; BoxCollider col = rampGO.AddComponent(); col.center = Vector3.zero; @@ -380,153 +454,195 @@ private void CreateStairRamps() #endregion - #region Private Methods — Source Collection + #region Private Methods — Ground Planes /// - /// Scan the building hierarchy for BoxColliders and create NavMeshBuildSources. - /// Sources are in local building coordinates so BuildNavMeshData can transform - /// them using the building's world position and rotation. + /// Add flat NavMeshBuildSource boxes at ground level past each stair base. + /// These provide walkable surface at ground level that connects the ramp bottom + /// to the edge of the baked NavMesh, bridging the gap created by obstacle carving. + /// Sources are in local building coordinates. /// - private List CollectBuildingSources() + private void AddGroundPlanes(List sources) { - var sources = new List(); - BoxCollider[] colliders = _buildingRoot.GetComponentsInChildren(); + float extension = Constants.NavMesh.GroundPatchExtension; - foreach (BoxCollider collider in colliders) + foreach (NavMeshDoorwayInfo doorway in _doorways) { - // Compute world-space center, then convert to local building space - Vector3 worldCenter = collider.transform.TransformPoint(collider.center); - Vector3 localCenter = _buildingRoot.InverseTransformPoint(worldCenter); + if (!doorway.StairBasePosition.HasValue) continue; - // Compute local rotation relative to building root - Quaternion localRot = Quaternion.Inverse(_buildingRoot.rotation) * collider.transform.rotation; + Vector3 stairBase = doorway.StairBasePosition.Value; + Vector3 outward = -doorway.InwardNormal; - // Actual box dimensions = collider size * transform scale - Vector3 worldSize = Vector3.Scale(collider.size, collider.transform.lossyScale); + // Ground plane centered between stair base and the far edge + Vector3 patchCenter = stairBase + outward * (extension / 2f); + + float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinRampWidth) + + Constants.NavMesh.RampErosionBuffer; + + // Align the plane with the outward direction + Quaternion patchRot = Quaternion.LookRotation(outward, Vector3.up); sources.Add(new NavMeshBuildSource { shape = NavMeshBuildSourceShape.Box, - size = worldSize, - transform = Matrix4x4.TRS(localCenter, localRot, Vector3.one), + size = new Vector3(rampWidth, Constants.NavMesh.RampColliderThickness, extension), + transform = Matrix4x4.TRS(patchCenter, patchRot, Vector3.one), area = 0 }); } - - return sources; } + #endregion + + #region Private Methods — Doorway Source Filtering + /// - /// Compute an encapsulating bounds from collected NavMesh build sources. + /// Remove any collected source whose center falls inside a door opening volume. + /// Door prefabs (e.g. wooden doors, sliding doors) have colliders that + /// source collection picks up. These create low-overhead obstructions that + /// block NavMesh in the doorway. + /// Must be called after collecting sources but before adding manual sources + /// (ground planes, thresholds, blocker). /// - private Bounds ComputeBoundsFromSources(List sources) + private void FilterDoorwaySources(List sources) { - // Start with a reasonable default based on room size - var bounds = new Bounds( - new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, _roomSize.z / 2f), - _roomSize); - - foreach (NavMeshBuildSource source in sources) + for (int i = sources.Count - 1; i >= 0; i--) { - // Extract position from the source transform matrix - Vector3 sourcePos = new Vector3( - source.transform.m03, - source.transform.m13, - source.transform.m23); - - // Expand bounds to include source position + half size - var sourceBounds = new Bounds(sourcePos, source.size); - bounds.Encapsulate(sourceBounds); - } + NavMeshBuildSource s = sources[i]; + // Source positions from CollectSources are in WORLD space, + // but doorway coordinates are in LOCAL building space. + // Transform doorway geometry to world space for comparison. + Vector3 pos = new Vector3(s.transform.m03, s.transform.m13, s.transform.m23); - return bounds; + foreach (NavMeshDoorwayInfo doorway in _doorways) + { + Vector3 worldCenter = _buildingRoot.TransformPoint(doorway.Center); + Vector3 worldNormal = _buildingRoot.TransformDirection(doorway.InwardNormal); + Vector3 worldTangent = new Vector3(-worldNormal.z, 0f, worldNormal.x); + + Vector3 delta = pos - worldCenter; + float normalDist = Mathf.Abs(Vector3.Dot(delta, worldNormal)); + float tangentDist = Mathf.Abs(Vector3.Dot(delta, worldTangent)); + float normalThreshold = doorway.WallThickness / 2f + Constants.NavMesh.DoorFilterNormalPadding; + + // Y check in world space: source must be between floor level and door top + float floorY = worldCenter.y; + if (normalDist <= normalThreshold && + tangentDist <= doorway.Width / 2f && + pos.y > floorY && pos.y < floorY + doorway.Height) + { + sources.RemoveAt(i); + break; + } + } + } } #endregion - #region Private Methods — Interior Links + #region Private Methods — Doorway Thresholds /// - /// Create NavMeshLinks at each interior doorway to connect rooms through walls. + /// Add flat walkable sources at each doorway to bridge the wall-thickness gap + /// between the ramp top (outer wall face) and the floor (inner wall face). + /// Without these, the NavMesh has a disconnected gap at every doorway. + /// Sources are in local building coordinates. /// - private void CreateInteriorDoorwayLinks() + private void AddDoorwayThresholds(List sources) { - foreach (DoorwayInfo doorway in _interiorDoorways) + foreach (NavMeshDoorwayInfo doorway in _doorways) { - Vector3 facingDir = doorway.FacesAlongZ ? Vector3.forward : Vector3.right; - float halfWall = doorway.WallThickness / 2f + Constants.NavMesh.LinkOffset; - - var linkData = new NavMeshLinkData(); - linkData.startPosition = doorway.Center - facingDir * halfWall; - linkData.endPosition = doorway.Center + facingDir * halfWall; - linkData.width = doorway.Width; - linkData.bidirectional = true; - linkData.area = 0; - linkData.agentTypeID = _agentTypeID; - linkData.costModifier = Constants.NavMesh.DefaultLinkCostModifier; - - NavMeshLinkInstance link = NavMesh.AddLink( - linkData, - _buildingRoot.position, - _buildingRoot.rotation); - - _links.Add(link); + if (!doorway.StairBasePosition.HasValue) continue; + + // Threshold centered in the wall at floor height (Y=0). + // Extends 0.2m past each wall face for robust overlap with ramp and floor. + Vector3 thresholdCenter = doorway.Center + + doorway.InwardNormal * (doorway.WallThickness / 2f); + + float thresholdDepth = doorway.WallThickness + 0.4f; + float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinRampWidth) + + Constants.NavMesh.RampErosionBuffer; + + Quaternion rot = Quaternion.LookRotation(doorway.InwardNormal, Vector3.up); + + sources.Add(new NavMeshBuildSource + { + shape = NavMeshBuildSourceShape.Box, + size = new Vector3(rampWidth, Constants.NavMesh.RampColliderThickness, thresholdDepth), + transform = Matrix4x4.TRS(thresholdCenter, rot, Vector3.one), + area = 0 + }); } } #endregion - #region Private Methods — Exterior Links + #region Private Methods — Source Collection /// - /// Create NavMeshLinks at each exterior doorway to connect interior to exterior NavMesh. - /// When stairs exist, the link is placed at the stair base (ground level). - /// Without stairs, the link bridges directly through the wall. + /// Scan the building hierarchy for BoxColliders and create NavMeshBuildSources. + /// Excludes colliders under the "Stairs" folder (steps block the ramp surface) + /// and the "Foundation" folder (bottom face creates a phantom walkable surface). + /// Sources are in local building coordinates so BuildNavMeshData can transform + /// them using the building's world position and rotation. /// - private void CreateExteriorDoorwayLinks() + private List CollectBuildingSources() { - foreach (ExteriorDoorwayInfo doorway in _exteriorDoorways) + // Find stair transforms to exclude (same logic as the Mono markup path) + var excludedRoots = new HashSet(); + foreach (Transform child in _buildingRoot) { - float halfWall = doorway.WallThickness / 2f + Constants.NavMesh.LinkOffset; - - Vector3 interiorEnd; - Vector3 exteriorEnd; - - if (doorway.StairBasePosition.HasValue) + if (child.name == Constants.Spatial.StairsFolderName || + child.name == Constants.Spatial.FoundationFolderName) { - // Interior endpoint at floor level (inside the door) — on our runtime NavMesh. - // Exterior endpoint at ground level (outside the stair base) — on the game's NavMesh. - // The invisible ramp provides walkable surface between floor and ground for - // NPCs already on our NavMesh; the link handles the cross-mesh bridge. - interiorEnd = doorway.Center + doorway.InwardNormal * halfWall; - exteriorEnd = doorway.StairBasePosition.Value - - doorway.InwardNormal * Constants.NavMesh.LinkOffset; + excludedRoots.Add(child); } - else + } + + var sources = new List(); + BoxCollider[] colliders = _buildingRoot.GetComponentsInChildren(); + + foreach (BoxCollider collider in colliders) + { + // Skip colliders under excluded stair roots + if (IsChildOfAny(collider.transform, excludedRoots)) continue; + + // Compute world-space center, then convert to local building space + Vector3 worldCenter = collider.transform.TransformPoint(collider.center); + Vector3 localCenter = _buildingRoot.InverseTransformPoint(worldCenter); + + // Compute local rotation relative to building root + Quaternion localRot = Quaternion.Inverse(_buildingRoot.rotation) * collider.transform.rotation; + + // Actual box dimensions = collider size * transform scale + Vector3 worldSize = Vector3.Scale(collider.size, collider.transform.lossyScale); + + sources.Add(new NavMeshBuildSource { - // No foundation: link directly through the wall at floor level - interiorEnd = doorway.Center + doorway.InwardNormal * halfWall; - exteriorEnd = doorway.Center - doorway.InwardNormal * halfWall; - } + shape = NavMeshBuildSourceShape.Box, + size = worldSize, + transform = Matrix4x4.TRS(localCenter, localRot, Vector3.one), + area = 0 + }); + } + + return sources; + } - var linkData = new NavMeshLinkData(); - linkData.startPosition = interiorEnd; - linkData.endPosition = exteriorEnd; - linkData.width = Mathf.Max(doorway.Width, Constants.NavMesh.MinLinkWidth); - linkData.bidirectional = true; - linkData.area = 0; - linkData.agentTypeID = _agentTypeID; - linkData.costModifier = Constants.NavMesh.DefaultLinkCostModifier; - - NavMeshLinkInstance link = NavMesh.AddLink( - linkData, - _buildingRoot.position, - _buildingRoot.rotation); - - _links.Add(link); + /// + /// Check whether is a descendant of any transform in . + /// + private static bool IsChildOfAny(Transform t, HashSet roots) + { + Transform current = t.parent; + while (current != null) + { + if (roots.Contains(current)) return true; + current = current.parent; } + return false; } #endregion } -} \ No newline at end of file +} diff --git a/Building/Structural/InteriorWallBuilder.cs b/Building/Structural/InteriorWallBuilder.cs index e113de6..073adf3 100644 --- a/Building/Structural/InteriorWallBuilder.cs +++ b/Building/Structural/InteriorWallBuilder.cs @@ -88,7 +88,6 @@ public sealed class DoorwayInfo /// /// True if the wall's normal faces along Z (i.e., the wall runs along X). - /// Used to determine NavMeshLink start/end offset direction. /// public bool FacesAlongZ { get; } diff --git a/Utils/Constants.cs b/Utils/Constants.cs index a1dda55..8c38265 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -151,6 +151,15 @@ public static class Spatial /// to exclude them from NavMesh source collection. /// public const string StairsFolderName = "Stairs"; + + /// + /// GameObject folder name for foundation geometry under the building root. + /// Excluded from NavMesh source collection because the foundation box's bottom + /// face creates a phantom walkable surface at ground level inside the building. + /// The floor collider provides the correct walkable surface instead. + /// + public const string FoundationFolderName = "Foundation"; + } /// @@ -293,24 +302,18 @@ public static class Roof } /// - /// NavMesh repair and link constants. + /// NavMesh repair constants. /// public static class NavMesh { /// - /// Offset from doorway center to NavMeshLink endpoint, in meters. - /// Must be large enough to land on the walkable surface on each side of the wall. + /// Minimum width for stair ramps in meters. + /// Wider ramps allow more NPCs to traverse simultaneously. /// - public const float LinkOffset = 0.3f; + public const float MinRampWidth = 3.0f; /// - /// Minimum width for exterior NavMeshLinks in meters. - /// Wider links allow more NPCs to traverse simultaneously. - /// - public const float MinLinkWidth = 3.0f; - - /// - /// Extra width added to stair ramps beyond the link width to compensate + /// Extra width added to stair ramps beyond the door width to compensate /// for NavMesh agent-radius erosion on both edges. /// public const float RampErosionBuffer = 1.0f; @@ -343,19 +346,22 @@ public static class NavMesh public const float VerifyTestYOffset = 0.1f; /// - /// Padding added to the ground obstacle beyond the room footprint in meters. + /// Height of the ground-carving NavMeshObstacle in meters. /// - public const float ObstacleExpand = 0.5f; + public const float ObstacleHeight = 0.5f; /// - /// Height of the ground-carving NavMeshObstacle in meters. + /// Distance past the stair base that the ground plane extends, in meters. + /// Provides walkable surface at ground level between the ramp bottom and the + /// baked NavMesh, bridging the gap created by obstacle carving. /// - public const float ObstacleHeight = 0.5f; + public const float GroundPatchExtension = 3.0f; /// - /// Default cost modifier for NavMeshLinks. -1 uses the NavMesh area cost. + /// Extra padding beyond half wall thickness when filtering door opening sources. + /// Accounts for colliders slightly offset from the wall center plane. /// - public const float DefaultLinkCostModifier = -1f; + public const float DoorFilterNormalPadding = 0.15f; } /// From 37e74c3788e2e534176efc911cf7489ca7ef7aac Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 9 Mar 2026 17:56:50 -0400 Subject: [PATCH 23/64] fix: replace foreach Transform with GetChild for IL2CPP compatibility --- Building/NavMeshRepairer.cs | 3 ++- Extensions/GameObjectExtensions.cs | 4 ++-- Extensions/TransformExtensions.cs | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Building/NavMeshRepairer.cs b/Building/NavMeshRepairer.cs index a178091..f2f337c 100644 --- a/Building/NavMeshRepairer.cs +++ b/Building/NavMeshRepairer.cs @@ -590,8 +590,9 @@ private List CollectBuildingSources() { // Find stair transforms to exclude (same logic as the Mono markup path) var excludedRoots = new HashSet(); - foreach (Transform child in _buildingRoot) + for (int i = 0; i < _buildingRoot.childCount; i++) { + Transform child = _buildingRoot.GetChild(i); if (child.name == Constants.Spatial.StairsFolderName || child.name == Constants.Spatial.FoundationFolderName) { diff --git a/Extensions/GameObjectExtensions.cs b/Extensions/GameObjectExtensions.cs index 31a05df..f615b9b 100644 --- a/Extensions/GameObjectExtensions.cs +++ b/Extensions/GameObjectExtensions.cs @@ -60,9 +60,9 @@ public static GameObject Show(this GameObject gameObject) public static GameObject SetLayerRecursively(this GameObject gameObject, int layer) { gameObject.layer = layer; - foreach (Transform child in gameObject.transform) + for (int i = 0; i < gameObject.transform.childCount; i++) { - SetLayerRecursively(child.gameObject, layer); + SetLayerRecursively(gameObject.transform.GetChild(i).gameObject, layer); } return gameObject; } diff --git a/Extensions/TransformExtensions.cs b/Extensions/TransformExtensions.cs index 48077d0..8722d89 100644 --- a/Extensions/TransformExtensions.cs +++ b/Extensions/TransformExtensions.cs @@ -37,9 +37,9 @@ public static Transform SetLocalPosition(this Transform transform, float? x = nu /// public static void DestroyChildren(this Transform transform) { - foreach (Transform child in transform) + for (int i = 0; i < transform.childCount; i++) { - Object.Destroy(child.gameObject); + Object.Destroy(transform.GetChild(i).gameObject); } } From 71d20c16896d914aea2d57d973ae20828c3909a4 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 10 Mar 2026 11:42:58 -0400 Subject: [PATCH 24/64] feat(Building): add per-wall material and color overrides for exterior walls --- Building/BuildingBuilder.cs | 26 +++++ Building/Structural/WallAppearance.cs | 28 ++++++ Building/Structural/WallBuilder.cs | 139 ++++++++++++++++++-------- 3 files changed, 151 insertions(+), 42 deletions(-) create mode 100644 Building/Structural/WallAppearance.cs diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 1de8f1e..a68a8f4 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -251,6 +251,32 @@ public BuildingBuilder AddWalls( return this; } + /// + /// Add walls with fine-grained control over openings and per-wall appearance. + /// Walls without an appearance override use the palette defaults. + /// + /// North wall opening configuration + /// South wall opening configuration + /// East wall opening configuration + /// West wall opening configuration + /// Per-wall material/color overrides keyed by wall side + /// This builder for chaining + public BuildingBuilder AddWalls( + WallOpening? north, + WallOpening? south, + WallOpening? east, + WallOpening? west, + IReadOnlyDictionary wallAppearances) + { + _northOpening = north; + _southOpening = south; + _eastOpening = east; + _westOpening = west; + + GetWallBuilder().BuildWalls(north, south, east, west, wallAppearances); + return this; + } + #endregion #region Interior Walls diff --git a/Building/Structural/WallAppearance.cs b/Building/Structural/WallAppearance.cs new file mode 100644 index 0000000..056336a --- /dev/null +++ b/Building/Structural/WallAppearance.cs @@ -0,0 +1,28 @@ +using UnityEngine; + +namespace S1MAPI.Building.Structural +{ + /// + /// Optional material and color overrides for a single exterior wall side. + /// When a value is null, the palette default is used. + /// + public sealed class WallAppearance + { + /// Optional wall color override. Null uses the palette default. + public Color? Color { get; } + + /// Optional wall material override. Null uses the palette default. + public Material? Material { get; } + + /// + /// Create a wall appearance override. + /// + /// Optional color override (null = use palette) + /// Optional material override (null = use palette) + public WallAppearance(Color? color = null, Material? material = null) + { + Color = color; + Material = material; + } + } +} diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index 85649e0..e6ec86c 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using S1MAPI.Building.Config; using S1MAPI.ProceduralMesh; using S1MAPI.Utils; @@ -174,6 +175,7 @@ public sealed class WallBuilder private readonly Vector3 _roomSize; private readonly float _wallThickness; private readonly BuildingPalette _palette; + private IReadOnlyDictionary? _wallOverrides; private GameObject? _wallsContainer; #endregion @@ -230,6 +232,33 @@ public GameObject BuildWalls( return _wallsContainer; } + /// + /// Build all four walls with specified openings and per-wall appearance overrides. + /// + /// Opening for north wall (or null for solid) + /// Opening for south wall (or null for solid) + /// Opening for east wall (or null for solid) + /// Opening for west wall (or null for solid) + /// Per-wall material/color overrides (null entries use palette defaults) + /// The walls container GameObject + public GameObject BuildWalls( + WallOpening? northOpening, + WallOpening? southOpening, + WallOpening? eastOpening, + WallOpening? westOpening, + IReadOnlyDictionary wallOverrides) + { + _wallOverrides = wallOverrides; + try + { + return BuildWalls(northOpening, southOpening, eastOpening, westOpening); + } + finally + { + _wallOverrides = null; + } + } + /// /// Build a single wall with optional opening. /// @@ -240,21 +269,24 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) { _wallsContainer ??= BuildingUtilities.CreateFolder("Walls", _parent); + Color wallColor = GetWallColor(side); + Material? wallMaterial = GetWallMaterial(side); + var (position, size, isVertical) = GetWallTransform(side); string wallName = $"{side}Wall"; if (opening == null || opening.Type == WallOpeningType.None) { - return CreateSolidWall(wallName, position, size); + return CreateSolidWall(wallName, position, size, wallColor, wallMaterial); } return opening.Type switch { WallOpeningType.Door when opening.LeftWindow != null || opening.RightWindow != null - => CreateWallWithDoorAndWindows(wallName, position, size, opening, isVertical), - WallOpeningType.Door => CreateWallWithDoor(wallName, position, size, opening, isVertical), - WallOpeningType.Window => CreateWallWithWindow(wallName, position, size, opening, isVertical), - _ => CreateSolidWall(wallName, position, size) + => CreateWallWithDoorAndWindows(wallName, position, size, opening, isVertical, wallColor, wallMaterial), + WallOpeningType.Door => CreateWallWithDoor(wallName, position, size, opening, isVertical, wallColor, wallMaterial), + WallOpeningType.Window => CreateWallWithWindow(wallName, position, size, opening, isVertical, wallColor, wallMaterial), + _ => CreateSolidWall(wallName, position, size, wallColor, wallMaterial) }; } @@ -293,14 +325,14 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) }; } - private GameObject CreateSolidWall(string name, Vector3 position, Vector3 size) + private GameObject CreateSolidWall(string name, Vector3 position, Vector3 size, Color wallColor, Material? wallMaterial) { - GameObject wall = PrimitiveBuilder.CreateBox(name, position, size, _palette.WallColor, _wallsContainer!.transform); - ApplyWallMaterial(wall); + GameObject wall = PrimitiveBuilder.CreateBox(name, position, size, wallColor, _wallsContainer!.transform); + ApplyWallMaterial(wall, wallMaterial); return wall; } - private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical) + private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical, Color wallColor, Material? wallMaterial) { GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); @@ -326,8 +358,8 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w Vector3 leftSize = isVertical ? new Vector3(_wallThickness, wallHeight, leftWidth) : new Vector3(leftWidth, wallHeight, _wallThickness); - GameObject left = PrimitiveBuilder.CreateBox($"{name}_Left", wallCenter + doorShift + leftOffset, leftSize, _palette.WallColor, container.transform); - ApplyWallMaterial(left); + GameObject left = PrimitiveBuilder.CreateBox($"{name}_Left", wallCenter + doorShift + leftOffset, leftSize, wallColor, container.transform); + ApplyWallMaterial(left, wallMaterial); } // Right segment @@ -338,8 +370,8 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w Vector3 rightSize = isVertical ? new Vector3(_wallThickness, wallHeight, rightWidth) : new Vector3(rightWidth, wallHeight, _wallThickness); - GameObject right = PrimitiveBuilder.CreateBox($"{name}_Right", wallCenter + doorShift + rightOffset, rightSize, _palette.WallColor, container.transform); - ApplyWallMaterial(right); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_Right", wallCenter + doorShift + rightOffset, rightSize, wallColor, container.transform); + ApplyWallMaterial(right, wallMaterial); } // Top segment (wall above door) @@ -351,14 +383,14 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w : new Vector3(doorWidth, topHeight, _wallThickness); float topCenterY = wallHeight / 2f - topHeight / 2f; Vector3 topOffset = Vector3.up * topCenterY; - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + doorShift + topOffset, topSize, _palette.WallColor, container.transform); - ApplyWallMaterial(top); + GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + doorShift + topOffset, topSize, wallColor, container.transform); + ApplyWallMaterial(top, wallMaterial); } return container; } - private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical) + private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical, Color wallColor, Material? wallMaterial) { GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); @@ -378,14 +410,14 @@ private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, if (leftSideWidth > 0f) { BuildDoorSideSegment(name, "_Left", shiftedCenter, doorWidth, wallHeight, - leftSideWidth, opening.LeftWindow, isVertical, true, container.transform); + leftSideWidth, opening.LeftWindow, isVertical, true, container.transform, wallColor, wallMaterial); } // Right side (may contain a window) if (rightSideWidth > 0f) { BuildDoorSideSegment(name, "_Right", shiftedCenter, doorWidth, wallHeight, - rightSideWidth, opening.RightWindow, isVertical, false, container.transform); + rightSideWidth, opening.RightWindow, isVertical, false, container.transform, wallColor, wallMaterial); } // Top segment (wall above door) — same as CreateWallWithDoor @@ -397,8 +429,8 @@ private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, : new Vector3(doorWidth, topHeight, _wallThickness); float topCenterY = wallHeight / 2f - topHeight / 2f; Vector3 topOffset = Vector3.up * topCenterY; - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", shiftedCenter + topOffset, topSize, _palette.WallColor, container.transform); - ApplyWallMaterial(top); + GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", shiftedCenter + topOffset, topSize, wallColor, container.transform); + ApplyWallMaterial(top, wallMaterial); } return container; @@ -407,7 +439,8 @@ private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, private void BuildDoorSideSegment( string wallName, string suffix, Vector3 wallCenter, float doorWidth, float wallHeight, float fullSideWidth, - WallOpening? sideWindow, bool isVertical, bool isLeftSide, Transform parent) + WallOpening? sideWindow, bool isVertical, bool isLeftSide, Transform parent, + Color wallColor, Material? wallMaterial) { float dirSign = isLeftSide ? -1f : 1f; @@ -420,8 +453,8 @@ private void BuildDoorSideSegment( Vector3 size = isVertical ? new Vector3(_wallThickness, wallHeight, fullSideWidth) : new Vector3(fullSideWidth, wallHeight, _wallThickness); - GameObject solid = PrimitiveBuilder.CreateBox($"{wallName}{suffix}", wallCenter + offset, size, _palette.WallColor, parent); - ApplyWallMaterial(solid); + GameObject solid = PrimitiveBuilder.CreateBox($"{wallName}{suffix}", wallCenter + offset, size, wallColor, parent); + ApplyWallMaterial(solid, wallMaterial); return; } @@ -438,8 +471,8 @@ private void BuildDoorSideSegment( Vector3 stripSize = isVertical ? new Vector3(_wallThickness, wallHeight, stripWidth) : new Vector3(stripWidth, wallHeight, _wallThickness); - GameObject strip = PrimitiveBuilder.CreateBox($"{wallName}{suffix}", wallCenter + stripOffset, stripSize, _palette.WallColor, parent); - ApplyWallMaterial(strip); + GameObject strip = PrimitiveBuilder.CreateBox($"{wallName}{suffix}", wallCenter + stripOffset, stripSize, wallColor, parent); + ApplyWallMaterial(strip, wallMaterial); // Window section in the remaining area (no overlap with strip so InsetDoorWallSegments works) float winSectionCenterOffset = doorWidth / 2f + stripWidth + windowSectionWidth / 2f; @@ -450,14 +483,14 @@ private void BuildDoorSideSegment( // Shift window toward door by stripWidth/2 so it centers in the full side width float windowOffset = -dirSign * stripWidth / 2f; CreateWindowInSection($"{wallName}{suffix}Win", winSectionCenter, windowSectionWidth, wallHeight, - sideWindow, isVertical, parent, windowOffset); + sideWindow, isVertical, parent, windowOffset, wallColor, wallMaterial); } - private GameObject CreateWallWithWindow(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical) + private GameObject CreateWallWithWindow(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical, Color wallColor, Material? wallMaterial) { GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); float wallWidth = isVertical ? wallSize.z : wallSize.x; - CreateWindowInSection(name, wallCenter, wallWidth, wallSize.y, opening, isVertical, container.transform, opening.Offset); + CreateWindowInSection(name, wallCenter, wallWidth, wallSize.y, opening, isVertical, container.transform, opening.Offset, wallColor, wallMaterial); return container; } @@ -465,7 +498,7 @@ private void CreateWindowInSection( string namePrefix, Vector3 sectionCenter, float sectionWidth, float sectionHeight, WallOpening window, bool isVertical, Transform parent, - float windowOffset = 0f) + float windowOffset, Color wallColor, Material? wallMaterial) { int paneCount = Mathf.Max(1, window.Count); float windowWidth; @@ -523,8 +556,8 @@ private void CreateWindowInSection( ? new Vector3(_wallThickness, windowBottom, sectionWidth) : new Vector3(sectionWidth, windowBottom, _wallThickness); Vector3 bottomOffset = Vector3.down * (halfHeight - windowBottom / 2f); - GameObject bottom = PrimitiveBuilder.CreateBox($"{namePrefix}_Bottom", sectionCenter + bottomOffset, bottomSize, _palette.WallColor, parent); - ApplyWallMaterial(bottom); + GameObject bottom = PrimitiveBuilder.CreateBox($"{namePrefix}_Bottom", sectionCenter + bottomOffset, bottomSize, wallColor, parent); + ApplyWallMaterial(bottom, wallMaterial); } // Top segment (header) — full section width, no shift @@ -534,8 +567,8 @@ private void CreateWindowInSection( ? new Vector3(_wallThickness, topHeight, sectionWidth) : new Vector3(sectionWidth, topHeight, _wallThickness); Vector3 topOffset = Vector3.up * (halfHeight - topHeight / 2f); - GameObject top = PrimitiveBuilder.CreateBox($"{namePrefix}_Top", sectionCenter + topOffset, topSize, _palette.WallColor, parent); - ApplyWallMaterial(top); + GameObject top = PrimitiveBuilder.CreateBox($"{namePrefix}_Top", sectionCenter + topOffset, topSize, wallColor, parent); + ApplyWallMaterial(top, wallMaterial); } // Side segments — asymmetric widths when window is offset @@ -554,8 +587,8 @@ private void CreateWindowInSection( Vector3 leftOffset = isVertical ? new Vector3(0f, windowCenterY, windowWidth / 2f + leftSideWidth / 2f) : new Vector3(-(windowWidth / 2f + leftSideWidth / 2f), windowCenterY, 0f); - GameObject leftSide = PrimitiveBuilder.CreateBox($"{namePrefix}_Left", windowCenter + leftOffset, leftSize, _palette.WallColor, parent); - ApplyWallMaterial(leftSide); + GameObject leftSide = PrimitiveBuilder.CreateBox($"{namePrefix}_Left", windowCenter + leftOffset, leftSize, wallColor, parent); + ApplyWallMaterial(leftSide, wallMaterial); } if (rightSideWidth > Constants.Window.SegmentThreshold) @@ -566,8 +599,8 @@ private void CreateWindowInSection( Vector3 rightOffset = isVertical ? new Vector3(0f, windowCenterY, -(windowWidth / 2f + rightSideWidth / 2f)) : new Vector3(windowWidth / 2f + rightSideWidth / 2f, windowCenterY, 0f); - GameObject rightSide = PrimitiveBuilder.CreateBox($"{namePrefix}_Right", windowCenter + rightOffset, rightSize, _palette.WallColor, parent); - ApplyWallMaterial(rightSide); + GameObject rightSide = PrimitiveBuilder.CreateBox($"{namePrefix}_Right", windowCenter + rightOffset, rightSize, wallColor, parent); + ApplyWallMaterial(rightSide, wallMaterial); } // Multi-pane window rendering @@ -625,8 +658,8 @@ private void CreateWindowInSection( GameObject divider = PrimitiveBuilder.CreateBox( $"{namePrefix}_Divider_{i}", windowCenter + dividerShift + new Vector3(0f, windowCenterY, 0f), - dividerSize, _palette.WallColor, parent); - ApplyWallMaterial(divider); + dividerSize, wallColor, parent); + ApplyWallMaterial(divider, wallMaterial); } } } @@ -666,12 +699,34 @@ private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windo } } - private void ApplyWallMaterial(GameObject wall) + private Color GetWallColor(WallSide side) + { + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.Color.HasValue) + { + return appearance.Color.Value; + } + return _palette.WallColor; + } + + private Material? GetWallMaterial(WallSide side) { - if (_palette.WallMaterial != null) + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.Material != null) + { + return appearance.Material; + } + return _palette.WallMaterial; + } + + private void ApplyWallMaterial(GameObject wall, Material? material) + { + if (material != null) { Renderer r = wall.GetComponent(); - if (r != null) r.material = _palette.WallMaterial; + if (r != null) r.material = material; } } From 5451ea93388b28e9bef126b1eb18a62ef1379ab1 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Thu, 12 Mar 2026 16:33:13 -0400 Subject: [PATCH 25/64] feat(Building): add skipWalls parameter to AddBaseMolding --- Building/BuildingBuilder.cs | 6 ++-- Building/Structural/DecorBuilder.cs | 51 +++++++++++++++++++---------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index a68a8f4..98b5e79 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -562,11 +562,13 @@ public BuildingBuilder AddDoorFrames(Material? material = null) /// Molding height in meters /// Molding depth in meters /// Optional material override + /// Wall sides to omit molding from (null = include all walls) /// This builder for chaining - public BuildingBuilder AddBaseMolding(float height = 0.3f, float depth = 0.1f, Material? material = null) + public BuildingBuilder AddBaseMolding(float height = 0.3f, float depth = 0.1f, Material? material = null, + IEnumerable? skipWalls = null) { GetDecorBuilder().AddBaseMolding(height, depth, material, - _northOpening, _southOpening, _eastOpening, _westOpening); + _northOpening, _southOpening, _eastOpening, _westOpening, skipWalls); return this; } diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index 9e3bd7c..31c50c9 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using S1MAPI.Building.Config; using S1MAPI.ProceduralMesh; using S1MAPI.Utils; @@ -426,11 +427,13 @@ public GameObject AddDoorFrames( /// South wall opening (doors create a gap) /// East wall opening (doors create a gap) /// West wall opening (doors create a gap) + /// Wall sides to omit molding from (null = include all walls) /// The molding container GameObject public GameObject AddBaseMolding( float height = 0.3f, float depth = 0.1f, Material? material = null, WallOpening? northOpening = null, WallOpening? southOpening = null, - WallOpening? eastOpening = null, WallOpening? westOpening = null) + WallOpening? eastOpening = null, WallOpening? westOpening = null, + IEnumerable? skipWalls = null) { float halfWidth = _roomSize.x / 2f; float halfDepth = _roomSize.z / 2f; @@ -441,29 +444,43 @@ public GameObject AddBaseMolding( Color color = _palette.TrimColor; Material? mat = material ?? _palette.TrimMaterial; + HashSet? skip = skipWalls != null ? new HashSet(skipWalls) : null; + // North (extends along X, centered on wall surface) - CreateMoldingSegments("BaseMolding_North", - new Vector3(halfWidth, height / 2f, _roomSize.z), - new Vector3(_roomSize.x + trimDepth, height, trimDepth), - _roomSize.x + trimDepth, false, northOpening, height, color, mat, container); + if (skip == null || !skip.Contains(WallSide.North)) + { + CreateMoldingSegments("BaseMolding_North", + new Vector3(halfWidth, height / 2f, _roomSize.z), + new Vector3(_roomSize.x + trimDepth, height, trimDepth), + _roomSize.x + trimDepth, false, northOpening, height, color, mat, container); + } // South (extends along X, centered on wall surface) - CreateMoldingSegments("BaseMolding_South", - new Vector3(halfWidth, height / 2f, 0f), - new Vector3(_roomSize.x + trimDepth, height, trimDepth), - _roomSize.x + trimDepth, false, southOpening, height, color, mat, container); + if (skip == null || !skip.Contains(WallSide.South)) + { + CreateMoldingSegments("BaseMolding_South", + new Vector3(halfWidth, height / 2f, 0f), + new Vector3(_roomSize.x + trimDepth, height, trimDepth), + _roomSize.x + trimDepth, false, southOpening, height, color, mat, container); + } // East (extends along Z, centered on wall surface) - CreateMoldingSegments("BaseMolding_East", - new Vector3(_roomSize.x, height / 2f, halfDepth), - new Vector3(trimDepth, height, _roomSize.z - trimDepth), - _roomSize.z - trimDepth, true, eastOpening, height, color, mat, container); + if (skip == null || !skip.Contains(WallSide.East)) + { + CreateMoldingSegments("BaseMolding_East", + new Vector3(_roomSize.x, height / 2f, halfDepth), + new Vector3(trimDepth, height, _roomSize.z - trimDepth), + _roomSize.z - trimDepth, true, eastOpening, height, color, mat, container); + } // West (extends along Z, centered on wall surface) - CreateMoldingSegments("BaseMolding_West", - new Vector3(0f, height / 2f, halfDepth), - new Vector3(trimDepth, height, _roomSize.z - trimDepth), - _roomSize.z - trimDepth, true, westOpening, height, color, mat, container); + if (skip == null || !skip.Contains(WallSide.West)) + { + CreateMoldingSegments("BaseMolding_West", + new Vector3(0f, height / 2f, halfDepth), + new Vector3(trimDepth, height, _roomSize.z - trimDepth), + _roomSize.z - trimDepth, true, westOpening, height, color, mat, container); + } return container; } From 4ce9cda84296253fd3296ef599447848dada9279 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Fri, 13 Mar 2026 17:11:55 -0400 Subject: [PATCH 26/64] feat(Building): add TerrainFlattener for runtime height and detail clearing --- Building/Structural/TerrainFlattener.cs | 519 ++++++++++++++++++++++++ Utils/Constants.cs | 5 + 2 files changed, 524 insertions(+) create mode 100644 Building/Structural/TerrainFlattener.cs diff --git a/Building/Structural/TerrainFlattener.cs b/Building/Structural/TerrainFlattener.cs new file mode 100644 index 0000000..25a9ab8 --- /dev/null +++ b/Building/Structural/TerrainFlattener.cs @@ -0,0 +1,519 @@ +using S1MAPI.Utils; +using UnityEngine; + +#if IL2CPP +using Il2CppInterop.Runtime; +using Il2CppInterop.Runtime.InteropTypes; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +#endif + +namespace S1MAPI.Building.Structural +{ + /// + /// Flattens terrain height and clears detail/grass under a building footprint. + /// Only lowers terrain — never raises it — to avoid creating dirt walls. + /// Works on both Mono and IL2CPP runtimes. + /// + public static class TerrainFlattener + { + #region IL2CPP ICalls + +#if IL2CPP + // Height ICalls — GetHeights managed wrapper crashes in Il2CppObjectPool, so we bypass it too + private delegate System.IntPtr Internal_GetHeightsDelegate( + System.IntPtr @this, int xBase, int yBase, int width, int height); + + private delegate void Internal_SetHeightsDelegate( + System.IntPtr @this, int xBase, int yBase, int totalWidth, int totalHeight, System.IntPtr heights); + + // Detail ICalls — both GetDetailLayer and SetDetailLayer are stripped + private delegate System.IntPtr GetDetailLayerDelegate( + System.IntPtr @this, int xBase, int yBase, int width, int height, int layer); + + private delegate void Internal_SetDetailLayerDelegate( + System.IntPtr @this, int xBase, int yBase, int totalWidth, int totalHeight, + int detailIndex, System.IntPtr data); + + private static Internal_GetHeightsDelegate? _getHeightsICall; + private static Internal_SetHeightsDelegate? _setHeightsICall; + private static bool _heightICallsResolved; + + private static GetDetailLayerDelegate? _getDetailLayerICall; + private static Internal_SetDetailLayerDelegate? _setDetailLayerICall; + private static bool _detailICallsResolved; + + /// + /// Resolve both Internal_GetHeights and Internal_SetHeights ICalls. + /// Returns true only if both resolved successfully. + /// + private static bool ResolveHeightICalls() + { + if (_heightICallsResolved) return _getHeightsICall != null && _setHeightsICall != null; + _heightICallsResolved = true; + + try + { + _getHeightsICall = IL2CPP.ResolveICall( + "UnityEngine.TerrainData::Internal_GetHeights"); + _setHeightsICall = IL2CPP.ResolveICall( + "UnityEngine.TerrainData::Internal_SetHeights"); + + if (_getHeightsICall != null && _setHeightsICall != null) + DebugLog.Info("[TerrainFlattener] Resolved height ICalls."); + else + DebugLog.Error("[TerrainFlattener] Height ICalls resolved to null."); + } + catch (System.Exception ex) + { + DebugLog.Error($"[TerrainFlattener] Failed to resolve height ICalls: {ex.Message}"); + _getHeightsICall = null; + _setHeightsICall = null; + } + + return _getHeightsICall != null && _setHeightsICall != null; + } + + /// + /// Resolve both GetDetailLayer and Internal_SetDetailLayer ICalls. + /// Returns true only if both resolved successfully. + /// + private static bool ResolveDetailICalls() + { + if (_detailICallsResolved) return _getDetailLayerICall != null && _setDetailLayerICall != null; + _detailICallsResolved = true; + + try + { + _getDetailLayerICall = IL2CPP.ResolveICall( + "UnityEngine.TerrainData::GetDetailLayer"); + _setDetailLayerICall = IL2CPP.ResolveICall( + "UnityEngine.TerrainData::Internal_SetDetailLayer"); + + if (_getDetailLayerICall != null && _setDetailLayerICall != null) + DebugLog.Info("[TerrainFlattener] Resolved detail layer ICalls."); + else + DebugLog.Warning("[TerrainFlattener] Detail layer ICalls resolved to null — grass clearing unavailable."); + } + catch (System.Exception ex) + { + DebugLog.Warning($"[TerrainFlattener] Failed to resolve detail layer ICalls: {ex.Message}"); + _getDetailLayerICall = null; + _setDetailLayerICall = null; + } + + return _getDetailLayerICall != null && _setDetailLayerICall != null; + } +#endif + + #endregion + + #region Public API + + /// + /// Flatten terrain under a building so the ground is level at the target height. + /// Only lowers terrain that is above the target — never raises terrain. + /// Optionally clears grass and detail layers in the flattened region. + /// + /// The positioned building root GameObject. + /// The building's room dimensions. + /// World Y to flatten to (typically foundation base). + /// Extra padding around the footprint in meters. + /// Clear grass and detail layers in the flattened region. + /// Distance in meters over which terrain smoothly transitions + /// from the flattened height back to natural terrain. 0 = hard edge. + public static void FlattenUnder( + GameObject buildingRoot, Vector3 roomSize, + float targetWorldY, float padding = Constants.Terrain.DefaultFlattenPadding, + bool clearDetails = true, float blendDistance = 0f) + { + Bounds innerBounds = ComputeXZBounds(buildingRoot.transform, roomSize, padding); + + // Expand sample region to include blend zone + Bounds sampleBounds = innerBounds; + if (blendDistance > 0f) + sampleBounds.Expand(new Vector3(blendDistance * 2f, 0f, blendDistance * 2f)); + + Terrain? terrain = FindCoveringTerrain(innerBounds); + if (terrain == null) + { + DebugLog.Warning("[TerrainFlattener] No terrain found covering building footprint."); + return; + } + + TerrainData tData = terrain.terrainData; + Vector3 terrainPos = terrain.transform.position; + Vector3 terrainSize = tData.size; + int hmRes = tData.heightmapResolution; + + // Convert expanded bounds to heightmap sample indices + int xStart = Mathf.Clamp(Mathf.FloorToInt((sampleBounds.min.x - terrainPos.x) / terrainSize.x * hmRes), 0, hmRes - 1); + int xEnd = Mathf.Clamp(Mathf.CeilToInt((sampleBounds.max.x - terrainPos.x) / terrainSize.x * hmRes), 0, hmRes - 1); + int zStart = Mathf.Clamp(Mathf.FloorToInt((sampleBounds.min.z - terrainPos.z) / terrainSize.z * hmRes), 0, hmRes - 1); + int zEnd = Mathf.Clamp(Mathf.CeilToInt((sampleBounds.max.z - terrainPos.z) / terrainSize.z * hmRes), 0, hmRes - 1); + + int sampleWidth = xEnd - xStart + 1; + int sampleHeight = zEnd - zStart + 1; + + if (sampleWidth <= 0 || sampleHeight <= 0) + { + DebugLog.Warning("[TerrainFlattener] Computed zero-size sample region."); + return; + } + + // Compute blend zone size in samples for each axis + int blendSamplesX = blendDistance > 0f + ? Mathf.CeilToInt(blendDistance / terrainSize.x * hmRes) + : 0; + int blendSamplesZ = blendDistance > 0f + ? Mathf.CeilToInt(blendDistance / terrainSize.z * hmRes) + : 0; + + float normalizedTarget = Mathf.Clamp01((targetWorldY - terrainPos.y) / terrainSize.y); + +#if MONO + FlattenMono(tData, xStart, zStart, sampleWidth, sampleHeight, normalizedTarget, + blendSamplesX, blendSamplesZ); +#elif IL2CPP + FlattenIl2Cpp(tData, xStart, zStart, sampleWidth, sampleHeight, normalizedTarget, + blendSamplesX, blendSamplesZ); +#endif + + if (clearDetails) + { + // Clear details only in the inner footprint, not the blend zone + ClearDetails(tData, terrainPos, terrainSize, innerBounds); + } + + // Force terrain to update visuals and collision + TerrainCollider? collider = terrain.GetComponent(); + if (collider != null) + { + collider.terrainData = null; + collider.terrainData = tData; + } + terrain.Flush(); + + DebugLog.Info($"[TerrainFlattener] Flattened {sampleWidth}x{sampleHeight} samples " + + $"to Y={targetWorldY:F2} on terrain '{terrain.name}'."); + } + + #endregion + + #region Private Methods — Terrain Lookup + + /// + /// Find the terrain whose XZ bounds cover the given world-space bounds. + /// Skips terrains with null terrainData. + /// + private static Terrain? FindCoveringTerrain(Bounds worldBounds) + { + foreach (Terrain t in Terrain.activeTerrains) + { + TerrainData? data; + try + { + data = t.terrainData; + if (data == null) continue; + } + catch (System.Exception) + { + continue; + } + + Vector3 tPos = t.transform.position; + Vector3 tSize = data.size; + + if (worldBounds.min.x >= tPos.x && worldBounds.max.x <= tPos.x + tSize.x && + worldBounds.min.z >= tPos.z && worldBounds.max.z <= tPos.z + tSize.z) + { + return t; + } + } + + return null; + } + + #endregion + + #region Private Methods — Bounds + + /// + /// Compute a padded world-space AABB from the building's rotated footprint. + /// Only uses XZ corners (Y is irrelevant for heightmap sampling). + /// + private static Bounds ComputeXZBounds(Transform building, Vector3 roomSize, float padding) + { + Vector3 c0 = building.TransformPoint(new Vector3(0f, 0f, 0f)); + Vector3 c1 = building.TransformPoint(new Vector3(roomSize.x, 0f, 0f)); + Vector3 c2 = building.TransformPoint(new Vector3(0f, 0f, roomSize.z)); + Vector3 c3 = building.TransformPoint(new Vector3(roomSize.x, 0f, roomSize.z)); + + var bounds = new Bounds(c0, Vector3.zero); + bounds.Encapsulate(c1); + bounds.Encapsulate(c2); + bounds.Encapsulate(c3); + bounds.Expand(new Vector3(padding * 2f, 0f, padding * 2f)); + + return bounds; + } + + /// + /// Compute a smoothstep blend factor for a sample position. + /// Returns 0 inside the inner flat zone, smoothly ramps to 1 at the outer blend edge. + /// + private static float ComputeBlendFactor( + int x, int z, int sampleWidth, int sampleHeight, + int blendSamplesX, int blendSamplesZ) + { + if (blendSamplesX <= 0 && blendSamplesZ <= 0) return 0f; + + // Normalized distance from inner bounds edge (0 = at edge, 1 = at outer limit) + float dx = 0f; + if (blendSamplesX > 0) + { + if (x < blendSamplesX) + dx = (float)(blendSamplesX - x) / blendSamplesX; + else if (x >= sampleWidth - blendSamplesX) + dx = (float)(x - (sampleWidth - 1 - blendSamplesX)) / blendSamplesX; + } + + float dz = 0f; + if (blendSamplesZ > 0) + { + if (z < blendSamplesZ) + dz = (float)(blendSamplesZ - z) / blendSamplesZ; + else if (z >= sampleHeight - blendSamplesZ) + dz = (float)(z - (sampleHeight - 1 - blendSamplesZ)) / blendSamplesZ; + } + + // Rectangular falloff — use max of axis distances + float t = Mathf.Clamp01(Mathf.Max(dx, dz)); + + // Smoothstep for natural-looking transition + return t * t * (3f - 2f * t); + } + + #endregion + + #region Private Methods — Detail Clearing + + /// + /// Clear grass and detail layers in the given world-space bounds. + /// Converts to detail map indices and delegates to the platform-specific implementation. + /// + private static void ClearDetails( + TerrainData tData, Vector3 terrainPos, Vector3 terrainSize, Bounds worldBounds) + { + int detailWidth = tData.detailWidth; + int detailHeight = tData.detailHeight; + + if (detailWidth <= 0 || detailHeight <= 0) return; + + // Convert world XZ to detail map indices + int dxStart = Mathf.Clamp(Mathf.FloorToInt((worldBounds.min.x - terrainPos.x) / terrainSize.x * detailWidth), 0, detailWidth - 1); + int dxEnd = Mathf.Clamp(Mathf.CeilToInt((worldBounds.max.x - terrainPos.x) / terrainSize.x * detailWidth), 0, detailWidth - 1); + int dzStart = Mathf.Clamp(Mathf.FloorToInt((worldBounds.min.z - terrainPos.z) / terrainSize.z * detailHeight), 0, detailHeight - 1); + int dzEnd = Mathf.Clamp(Mathf.CeilToInt((worldBounds.max.z - terrainPos.z) / terrainSize.z * detailHeight), 0, detailHeight - 1); + + int regionWidth = dxEnd - dxStart + 1; + int regionHeight = dzEnd - dzStart + 1; + + if (regionWidth <= 0 || regionHeight <= 0) return; + + // GetSupportedLayers returns which detail layers have data in this region +#if MONO + int[] layers = tData.GetSupportedLayers(dxStart, dzStart, regionWidth, regionHeight); +#elif IL2CPP + Il2CppStructArray layersArray = tData.GetSupportedLayers(dxStart, dzStart, regionWidth, regionHeight); + int[] layers = new int[layersArray.Length]; + for (int i = 0; i < layersArray.Length; i++) + layers[i] = layersArray[i]; +#endif + + if (layers.Length == 0) return; + + int cleared = 0; + foreach (int layer in layers) + { +#if MONO + if (ClearDetailLayerMono(tData, dxStart, dzStart, regionWidth, regionHeight, layer)) + cleared++; +#elif IL2CPP + if (ClearDetailLayerIl2Cpp(tData, dxStart, dzStart, regionWidth, regionHeight, layer)) + cleared++; +#endif + } + + if (cleared > 0) + { + DebugLog.Info($"[TerrainFlattener] Cleared {cleared} detail layer(s) " + + $"in {regionWidth}x{regionHeight} region."); + } + } + + #endregion + + #region Private Methods — Mono + +#if MONO + private static void FlattenMono( + TerrainData tData, int xStart, int zStart, + int sampleWidth, int sampleHeight, float normalizedTarget, + int blendSamplesX, int blendSamplesZ) + { + // GetHeights parameter order: (xBase, yBase, width, height) + // where yBase = z index, height = z count + // Returns float[zCount, xCount] indexed as [z, x] + float[,] heights = tData.GetHeights(xStart, zStart, sampleWidth, sampleHeight); + + bool modified = false; + for (int z = 0; z < sampleHeight; z++) + { + for (int x = 0; x < sampleWidth; x++) + { + float blend = ComputeBlendFactor(x, z, sampleWidth, sampleHeight, + blendSamplesX, blendSamplesZ); + // Lerp between target (blend=0) and original height (blend=1) + float blendedTarget = normalizedTarget + blend * (heights[z, x] - normalizedTarget); + + if (heights[z, x] > blendedTarget) + { + heights[z, x] = blendedTarget; + modified = true; + } + } + } + + if (modified) + { + tData.SetHeights(xStart, zStart, heights); + } + } + + private static bool ClearDetailLayerMono( + TerrainData tData, int xStart, int zStart, + int regionWidth, int regionHeight, int layer) + { + int[,] details = tData.GetDetailLayer(xStart, zStart, regionWidth, regionHeight, layer); + bool modified = false; + + for (int z = 0; z < regionHeight; z++) + { + for (int x = 0; x < regionWidth; x++) + { + if (details[z, x] != 0) + { + details[z, x] = 0; + modified = true; + } + } + } + + if (modified) + { + tData.SetDetailLayer(xStart, zStart, layer, details); + } + + return modified; + } +#endif + + #endregion + + #region Private Methods — IL2CPP + +#if IL2CPP + private static void FlattenIl2Cpp( + TerrainData tData, int xStart, int zStart, + int sampleWidth, int sampleHeight, float normalizedTarget, + int blendSamplesX, int blendSamplesZ) + { + if (!ResolveHeightICalls()) + { + DebugLog.Error("[TerrainFlattener] Cannot flatten terrain — height ICalls unavailable."); + return; + } + + System.IntPtr tDataPtr = IL2CPP.Il2CppObjectBaseToPtrNotNull(tData); + + // Call Internal_GetHeights directly — the managed GetHeights wrapper + // crashes in Il2CppObjectPool when wrapping the return value. + System.IntPtr heightsPtr = _getHeightsICall!(tDataPtr, xStart, zStart, sampleWidth, sampleHeight); + if (heightsPtr == System.IntPtr.Zero) + { + DebugLog.Error("[TerrainFlattener] Internal_GetHeights returned null."); + return; + } + + // Wrap the native 2D float array as a flat Il2CppStructArray for direct data access. + // The underlying memory is contiguous row-major floats [z0x0, z0x1, ..., z1x0, ...]. + var heightsArray = new Il2CppStructArray(heightsPtr); + System.Span data = heightsArray.AsSpan(); + int totalElements = sampleHeight * sampleWidth; + + bool modified = false; + for (int i = 0; i < totalElements; i++) + { + int z = i / sampleWidth; + int x = i % sampleWidth; + float blend = ComputeBlendFactor(x, z, sampleWidth, sampleHeight, + blendSamplesX, blendSamplesZ); + float blendedTarget = normalizedTarget + blend * (data[i] - normalizedTarget); + + if (data[i] > blendedTarget) + { + data[i] = blendedTarget; + modified = true; + } + } + + if (modified) + { + // Internal_SetHeights(this, xBase, yBase, width, height, heights) + _setHeightsICall!(tDataPtr, xStart, zStart, sampleWidth, sampleHeight, heightsPtr); + } + } + + private static bool ClearDetailLayerIl2Cpp( + TerrainData tData, int xStart, int zStart, + int regionWidth, int regionHeight, int layer) + { + if (!ResolveDetailICalls()) return false; + + System.IntPtr tDataPtr = IL2CPP.Il2CppObjectBaseToPtrNotNull(tData); + + // Call GetDetailLayer ICall to get a native int[,] array + System.IntPtr detailPtr = _getDetailLayerICall!(tDataPtr, xStart, zStart, regionWidth, regionHeight, layer); + if (detailPtr == System.IntPtr.Zero) + { + DebugLog.Warning($"[TerrainFlattener] GetDetailLayer returned null for layer {layer}."); + return false; + } + + // Wrap the native 2D int array and zero it out + var detailArray = new Il2CppStructArray(detailPtr); + System.Span data = detailArray.AsSpan(); + + bool modified = false; + for (int i = 0; i < data.Length; i++) + { + if (data[i] != 0) + { + data[i] = 0; + modified = true; + } + } + + if (modified) + { + // Internal_SetDetailLayer(this, xBase, yBase, width, height, layerIndex, data) + _setDetailLayerICall!(tDataPtr, xStart, zStart, regionWidth, regionHeight, layer, detailPtr); + } + + return modified; + } +#endif + + #endregion + } +} diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 8c38265..6f59232 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -407,6 +407,11 @@ public static class Terrain { "Road", "Sidewalk", "Wedge" }; + + /// + /// Default padding around terrain flattening bounds in meters. + /// + public const float DefaultFlattenPadding = 0.5f; } /// From a2030f25684c04e488f67b58392e839e7689e487 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 15 Mar 2026 19:16:20 -0400 Subject: [PATCH 27/64] feat(Building): add interior door frames with wall segment insetting --- Building/BuildingBuilder.cs | 13 ++++ Building/Structural/DecorBuilder.cs | 70 +++++++++++++++++++--- Building/Structural/InteriorWallBuilder.cs | 7 ++- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 98b5e79..83ba590 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -556,6 +556,19 @@ public BuildingBuilder AddDoorFrames(Material? material = null) return this; } + /// + /// Add trim-style door frames around interior doorway openings. + /// Must be called after AddInteriorWall. + /// + /// Optional material override + /// This builder for chaining + public BuildingBuilder AddInteriorDoorFrames(Material? material = null) + { + if (_interiorWallBuilder != null && _interiorWallBuilder.Doorways.Count > 0) + GetDecorBuilder().AddInteriorDoorFrames(_interiorWallBuilder.Doorways, material: material); + return this; + } + /// /// Add base molding around the bottom of the building. /// diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index 31c50c9..f416a92 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -416,6 +416,49 @@ public GameObject AddDoorFrames( return container; } + /// + /// Add trim-style door frames around interior doorway openings. + /// Uses recorded by . + /// + /// Interior doorway positions and dimensions + /// Width of the frame casing in meters + /// How far the frame extends past the wall surface + /// Optional material override (defaults to palette trim material) + /// The interior door frames container GameObject + public GameObject AddInteriorDoorFrames( + IReadOnlyList doorways, + float frameWidth = 0.12f, float frameProtrusion = 0.04f, + Material? material = null) + { + GameObject container = BuildingUtilities.CreateFolder("InteriorDoorFrames", _parent); + + Color color = _palette.TrimColor; + Material? mat = material ?? _palette.TrimMaterial; + + for (int i = 0; i < doorways.Count; i++) + { + DoorwayInfo doorway = doorways[i]; + float trimDepth = doorway.WallThickness + frameProtrusion; + bool isVertical = !doorway.FacesAlongZ; + + // Inset wall segments to make room for the frame (same as exterior) + if (doorway.WallContainer != null) + { + InsetDoorWallSegments(doorway.WallContainer.transform, + doorway.WallContainer.name, frameWidth, isVertical); + } + + // DoorwayInfo.Center is at door mid-height; CreateDoorFrame expects wall center (mid-wall-height) + Vector3 wallCenter = new Vector3(doorway.Center.x, _roomSize.y / 2f, doorway.Center.z); + + CreateDoorFrame($"InteriorDoorFrame_{i}", wallCenter, _roomSize.y, + doorway.Width, doorway.Height, 0f, + isVertical, frameWidth, trimDepth, color, mat, container); + } + + return container; + } + /// /// Add base molding around the bottom of the building. /// Automatically gaps around door openings so the molding does not clip through door frames. @@ -910,9 +953,14 @@ private void InsetDoorWallSegments(string wallName, float inset, bool isVertical Transform? wallContainer = _parent.Find($"Walls/{wallName}"); if (wallContainer == null) return; - Transform? left = wallContainer.Find($"{wallName}_Left"); - Transform? right = wallContainer.Find($"{wallName}_Right"); - Transform? top = wallContainer.Find($"{wallName}_Top"); + InsetDoorWallSegments(wallContainer, wallName, inset, isVertical); + } + + private static void InsetDoorWallSegments(Transform wallContainer, string childPrefix, float inset, bool isVertical) + { + Transform? left = wallContainer.Find($"{childPrefix}_Left"); + Transform? right = wallContainer.Find($"{childPrefix}_Right"); + Transform? top = wallContainer.Find($"{childPrefix}_Top"); // Shrink side segments away from the door opening if (left != null) @@ -954,13 +1002,21 @@ private void CreateDoorFrame( WallOpening opening, bool isVertical, float frameWidth, float trimDepth, Color color, Material? material, GameObject container) { - float doorWidth = opening.Width; - float doorHeight = opening.Height; + CreateDoorFrame(name, wallCenter, wallHeight, + opening.Width, opening.Height, opening.Offset, + isVertical, frameWidth, trimDepth, color, material, container); + } + private void CreateDoorFrame( + string name, Vector3 wallCenter, float wallHeight, + float doorWidth, float doorHeight, float doorOffset, + bool isVertical, float frameWidth, float trimDepth, + Color color, Material? material, GameObject container) + { // Shift frame to match door offset Vector3 doorShift = isVertical - ? new Vector3(0f, 0f, opening.Offset) - : new Vector3(opening.Offset, 0f, 0f); + ? new Vector3(0f, 0f, doorOffset) + : new Vector3(doorOffset, 0f, 0f); Vector3 doorCenter = wallCenter + doorShift; float jambYOffset = -(wallHeight - doorHeight) / 2f; diff --git a/Building/Structural/InteriorWallBuilder.cs b/Building/Structural/InteriorWallBuilder.cs index 073adf3..5be7efd 100644 --- a/Building/Structural/InteriorWallBuilder.cs +++ b/Building/Structural/InteriorWallBuilder.cs @@ -94,6 +94,9 @@ public sealed class DoorwayInfo /// Thickness of the wall containing this doorway. public float WallThickness { get; } + /// Wall container with Left/Right/Top segment children (used for frame insetting). + internal GameObject? WallContainer { get; set; } + /// /// Create a doorway info record. /// @@ -329,7 +332,9 @@ private GameObject CreateWallWithDoor( // Record doorway for future NavMesh (use shifted position) bool facesAlongZ = !isVertical; // Axis.X wall faces Z Vector3 doorCenter = new Vector3(shiftedCenter.x, doorHeight / 2f, shiftedCenter.z); - _doorways.Add(new DoorwayInfo(doorCenter, doorWidth, doorHeight, facesAlongZ, _wallThickness)); + var doorwayInfo = new DoorwayInfo(doorCenter, doorWidth, doorHeight, facesAlongZ, _wallThickness); + doorwayInfo.WallContainer = container; + _doorways.Add(doorwayInfo); return container; } From 0ab0bb7bbfa381afe16b932adda611fe98e2ddf7 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Mar 2026 09:26:54 -0400 Subject: [PATCH 28/64] fix(ProceduralMesh): enable all rendering layers for decal projection support --- ProceduralMesh/CustomMeshBuilder.cs | 4 +++- ProceduralMesh/ProceduralMeshBuilder.cs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ProceduralMesh/CustomMeshBuilder.cs b/ProceduralMesh/CustomMeshBuilder.cs index 4ae0154..72181ec 100644 --- a/ProceduralMesh/CustomMeshBuilder.cs +++ b/ProceduralMesh/CustomMeshBuilder.cs @@ -195,7 +195,9 @@ public GameObject Build() GameObject go = new GameObject(_name); go.AddComponent().mesh = mesh; - go.AddComponent().material = _material ?? MaterialPresets.Opaque(Color.white); + var renderer = go.AddComponent(); + renderer.material = _material ?? MaterialPresets.Opaque(Color.white); + renderer.renderingLayerMask = uint.MaxValue; DebugLog.Info($"Created GameObject: {_name}"); return go; diff --git a/ProceduralMesh/ProceduralMeshBuilder.cs b/ProceduralMesh/ProceduralMeshBuilder.cs index c36e383..c72945f 100644 --- a/ProceduralMesh/ProceduralMeshBuilder.cs +++ b/ProceduralMesh/ProceduralMeshBuilder.cs @@ -218,6 +218,7 @@ public GameObject Build() GameObject go = new GameObject(_name); MeshFilter filter = go.AddComponent(); MeshRenderer renderer = go.AddComponent(); + renderer.renderingLayerMask = uint.MaxValue; filter.mesh = mesh; From 5bf084bedb3dedb848517d70b81f95d6b3adcb95 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Mar 2026 09:29:29 -0400 Subject: [PATCH 29/64] refactor(Building): replace SnapToGrid with ComputeGridCellSize and add IsLivingEntity utility --- Building/BuildingUtilities.cs | 47 +++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/Building/BuildingUtilities.cs b/Building/BuildingUtilities.cs index fe414ee..997aa97 100644 --- a/Building/BuildingUtilities.cs +++ b/Building/BuildingUtilities.cs @@ -132,23 +132,44 @@ public static void ApplyOcclusionSettings(GameObject gameObject, bool allowOcclu #endregion - #region Public API - Grid Snapping - + #region Public API - Grid + /// - /// Snap a position to a grid + /// Compute the grid cell size for a room with the given dimensions. + /// Returns the largest square cell size ≤ + /// that evenly divides the room's X axis. The pathfinding grid uses this + /// same value so furniture placement and NPC navigation are always aligned. /// - /// The position to snap - /// The grid cell size - /// The snapped position - public static Vector3 SnapToGrid(Vector3 position, float gridSize = Constants.Spatial.DefaultGridSize) + public static float ComputeGridCellSize(float roomX, float roomZ) { - return new Vector3( - Mathf.Round(position.x / gridSize) * gridSize, - Mathf.Round(position.y / gridSize) * gridSize, - Mathf.Round(position.z / gridSize) * gridSize - ); + float target = Constants.Spatial.DefaultGridSize; + int nx = Mathf.Max(1, Mathf.CeilToInt(roomX / target)); + int nz = Mathf.Max(1, Mathf.CeilToInt(roomZ / target)); + return Mathf.Min(roomX / nx, roomZ / nz); } - + + + /// + /// Check whether a transform belongs to a living entity (player/NPC) by + /// looking for an anywhere in its root hierarchy. + /// Results are cached in the provided sets for efficiency. + /// + /// true if the transform's root contains an Animator. + internal static bool IsLivingEntity(Transform t, HashSet livingRoots, HashSet staticRoots) + { + int rootId = t.root.GetInstanceID(); + if (livingRoots.Contains(rootId)) return true; + if (staticRoots.Contains(rootId)) return false; + + if (t.root.GetComponentInChildren() != null) + { + livingRoots.Add(rootId); + return true; + } + staticRoots.Add(rootId); + return false; + } + #endregion #region Public API - Hierarchy Organization From fe5f29e78f222dd785380764f1c0745e747b63a0 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Mar 2026 09:31:53 -0400 Subject: [PATCH 30/64] fix(Building): harden TerrainClearer with living entity detection and vegetation keyword safety --- Building/Structural/TerrainClearer.cs | 66 ++++++++++++++++----------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/Building/Structural/TerrainClearer.cs b/Building/Structural/TerrainClearer.cs index c33ec2d..a07aed5 100644 --- a/Building/Structural/TerrainClearer.cs +++ b/Building/Structural/TerrainClearer.cs @@ -265,6 +265,9 @@ private static int ClearSceneObjects( HashSet preserved = BuildPreservedSet(options); HashSet toDestroy = new HashSet(); + var livingRoots = new HashSet(); + var staticRoots = new HashSet(); + foreach (Renderer r in allRenderers) { if (r == null) continue; @@ -279,6 +282,9 @@ private static int ClearSceneObjects( GameObject target = ResolveLodRoot(t.gameObject); + // Skip living entities (players, NPCs) + if (BuildingUtilities.IsLivingEntity(t, livingRoots, staticRoots)) continue; + // Pass 1: everything inside the building footprint, except protected objects. if (inFootprint && options.ClearSceneObjects) { @@ -300,39 +306,47 @@ private static int ClearSceneObjects( } } - // Catch-all: scan every Transform for renderer-less objects the first pass - // missed (audio triggers, tree rustle sounds, invisible scripts, etc.). - Transform[] allTransforms = UnityEngine.Object.FindObjectsOfType(); - foreach (Transform t in allTransforms) + // Catch-all: scan for renderer-less objects matching vegetation keywords + // (e.g. invisible tree rustle audio, vegetation scripts without meshes). + // SAFETY: only destroy childless objects whose name matches a vegetation + // keyword. AudioSource alone is NOT sufficient — the game attaches audio + // components to infrastructure objects that must not be destroyed. + if (options.VegetationKeywords != null && options.VegetationKeywords.Length > 0) { - if (t == null) continue; - if (t.GetComponent() != null) continue; - if (t.GetComponent() != null) continue; // Already handled above. - if (preserved.Contains(t.GetInstanceID())) continue; + Transform[] allTransforms = UnityEngine.Object.FindObjectsOfType(); + foreach (Transform t in allTransforms) + { + if (t == null) continue; + if (t.GetComponent() != null) continue; + if (t.GetComponent() != null) continue; // Already handled above. + if (preserved.Contains(t.GetInstanceID())) continue; + if (t.childCount > 0) continue; - bool inFootprint = footprintBounds.Contains(t.position); - bool inVegetationZone = !inFootprint && vegetationBounds.Contains(t.position); + bool inFootprint = footprintBounds.Contains(t.position); + bool inVegetationZone = !inFootprint && vegetationBounds.Contains(t.position); - if (!inFootprint && !inVegetationZone) continue; + if (!inFootprint && !inVegetationZone) continue; - GameObject target = t.gameObject; + GameObject target = t.gameObject; - if (inFootprint && options.ClearSceneObjects) - { - if (options.ProtectedKeywords != null - && MatchesKeyword(target.name, options.ProtectedKeywords)) + if (!MatchesKeyword(target.name, options.VegetationKeywords)) continue; - if (options.Filter != null && options.Filter(target)) continue; - toDestroy.Add(target); - continue; - } - if (inVegetationZone && options.ClearVegetation - && options.VegetationKeywords != null - && MatchesKeyword(target.name, options.VegetationKeywords)) - { - if (options.Filter != null && options.Filter(target)) continue; - toDestroy.Add(target); + if (inFootprint && options.ClearSceneObjects) + { + if (options.ProtectedKeywords != null + && MatchesKeyword(target.name, options.ProtectedKeywords)) + continue; + if (options.Filter != null && options.Filter(target)) continue; + toDestroy.Add(target); + continue; + } + + if (inVegetationZone && options.ClearVegetation) + { + if (options.Filter != null && options.Filter(target)) continue; + toDestroy.Add(target); + } } } From b1a032ce2a532d35f5e85cd6d42affbab995e0c6 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Mar 2026 10:15:59 -0400 Subject: [PATCH 31/64] refactor(Building): remove dead heightmap snapshot system from TerrainFlattener --- Building/Structural/TerrainFlattener.cs | 33 ++++++++++++++----------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/Building/Structural/TerrainFlattener.cs b/Building/Structural/TerrainFlattener.cs index 25a9ab8..f91f1ee 100644 --- a/Building/Structural/TerrainFlattener.cs +++ b/Building/Structural/TerrainFlattener.cs @@ -184,14 +184,7 @@ public static void FlattenUnder( ClearDetails(tData, terrainPos, terrainSize, innerBounds); } - // Force terrain to update visuals and collision - TerrainCollider? collider = terrain.GetComponent(); - if (collider != null) - { - collider.terrainData = null; - collider.terrainData = tData; - } - terrain.Flush(); + FlushTerrain(terrain); DebugLog.Info($"[TerrainFlattener] Flattened {sampleWidth}x{sampleHeight} samples " + $"to Y={targetWorldY:F2} on terrain '{terrain.name}'."); @@ -373,7 +366,6 @@ private static void FlattenMono( { float blend = ComputeBlendFactor(x, z, sampleWidth, sampleHeight, blendSamplesX, blendSamplesZ); - // Lerp between target (blend=0) and original height (blend=1) float blendedTarget = normalizedTarget + blend * (heights[z, x] - normalizedTarget); if (heights[z, x] > blendedTarget) @@ -385,9 +377,7 @@ private static void FlattenMono( } if (modified) - { tData.SetHeights(xStart, zStart, heights); - } } private static bool ClearDetailLayerMono( @@ -468,10 +458,7 @@ private static void FlattenIl2Cpp( } if (modified) - { - // Internal_SetHeights(this, xBase, yBase, width, height, heights) _setHeightsICall!(tDataPtr, xStart, zStart, sampleWidth, sampleHeight, heightsPtr); - } } private static bool ClearDetailLayerIl2Cpp( @@ -515,5 +502,23 @@ private static bool ClearDetailLayerIl2Cpp( #endif #endregion + + #region Private Methods — Flush + + /// + /// Force terrain to update visuals and collision data. + /// + private static void FlushTerrain(Terrain terrain) + { + TerrainCollider? collider = terrain.GetComponent(); + if (collider != null) + { + collider.terrainData = null; + collider.terrainData = terrain.terrainData; + } + terrain.Flush(); + } + + #endregion } } From 85c9898101f310c04d4b7d21bab82cbd12fdce6a Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 22 Mar 2026 15:50:37 -0400 Subject: [PATCH 32/64] feat(Building): unify NPC navigation on custom A* pathfinding Replace NavMeshRepairer with NavigationBuilder backed by InteriorPathGrid (A* walkability grid) and InteriorNavigatorCore (Harmony-patched NPC intercept). Extract all logic from InteriorNavigator MonoBehaviour into plain C# InteriorNavigatorCore to eliminate IL2CPP ClassInjector warnings. Works identically on both Mono and IL2CPP via reflection + MemberAccessor. --- Building/BuildingBuilder.cs | 56 +- Building/InteriorNavigator.cs | 31 + Building/InteriorNavigatorCore.cs | 1358 +++++++++++++++++++++++++++++ Building/InteriorPathGrid.cs | 737 ++++++++++++++++ Building/NavMeshRepairer.cs | 649 -------------- Building/NavigationBuilder.cs | 348 ++++++++ CODING_STANDARDS.md | 11 +- S1MAPI.csproj | 12 + Utils/Constants.cs | 68 +- 9 files changed, 2568 insertions(+), 702 deletions(-) create mode 100644 Building/InteriorNavigator.cs create mode 100644 Building/InteriorNavigatorCore.cs create mode 100644 Building/InteriorPathGrid.cs delete mode 100644 Building/NavMeshRepairer.cs create mode 100644 Building/NavigationBuilder.cs diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 83ba590..ce979a9 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -341,15 +341,14 @@ public BuildingBuilder AddInteriorWall( _interiorWallBuilder?.Doorways ?? (IReadOnlyList)System.Array.Empty(); /// - /// Create a configured for this building. + /// Create a configured for this building. /// Collects exterior and interior doorway positions, stair geometry, and building dimensions. - /// Call on the returned instance after positioning the building. + /// Call on the returned instance after positioning the building. /// - /// NavMesh agent type to build for (0 = default agent) - /// A configured repairer ready to build - public NavMeshRepairer CreateNavMeshRepairer(int agentTypeID = 0) + /// A configured builder ready to build + public NavigationBuilder CreateNavigationBuilder() { - var doorways = new List(); + var doorways = new List(); // Exterior doorways (with optional stair base positions) TryAddExteriorDoor(WallSide.North, _northOpening, doorways); @@ -357,18 +356,37 @@ public NavMeshRepairer CreateNavMeshRepairer(int agentTypeID = 0) TryAddExteriorDoor(WallSide.East, _eastOpening, doorways); TryAddExteriorDoor(WallSide.West, _westOpening, doorways); - // Interior doorways (for door panel collider filtering only) + // Interior doorways (threshold generation + door panel collider filtering) foreach (DoorwayInfo interior in InteriorDoorways) { Vector3 normal = interior.FacesAlongZ ? Vector3.forward : Vector3.right; - doorways.Add(new NavMeshDoorwayInfo( - interior.Center, interior.Width, interior.Height, - normal, interior.WallThickness)); + // DoorwayInfo.Center.y is at mid-door height; NavDoorwayInfo expects Y=0 (floor level) + Vector3 center = new Vector3(interior.Center.x, 0f, interior.Center.z); + doorways.Add(new NavDoorwayInfo( + center, interior.Width, interior.Height, + normal, interior.WallThickness, isInterior: true)); } - return new NavMeshRepairer( + return new NavigationBuilder( _root.transform, _roomSize, - doorways, agentTypeID, _foundationHeight); + doorways, _config.WallThickness, _foundationHeight); + } + + /// + /// Flatten terrain under the building footprint. + /// Must be called after the building is positioned in the scene. + /// + /// Extra padding around the footprint in meters. + /// Clear grass and detail layers in the flattened region. + /// Distance for smooth transition back to natural terrain. + public BuildingBuilder FlattenTerrain( + float padding = Constants.Terrain.DefaultFlattenPadding, + bool clearDetails = true, float blendDistance = Constants.Terrain.DefaultBlendDistance) + { + float targetWorldY = _root.transform.position.y - _foundationHeight; + TerrainFlattener.FlattenUnder( + _root, _roomSize, targetWorldY, padding, clearDetails, blendDistance); + return this; } #endregion @@ -725,6 +743,14 @@ public GameObject Build(Action postBuild) /// public BuildingConfig Config => _config; + /// + /// Grid cell size computed from room dimensions. + /// Furniture placement and interior pathfinding both use this value + /// so the two grids are always aligned. + /// + public float GridCellSize => + BuildingUtilities.ComputeGridCellSize(_roomSize.x, _roomSize.z); + #endregion #region Private Methods - Builder Access @@ -800,10 +826,10 @@ private float GetDoorOffset(WallSide wall) /// /// If is a door, compute its center, inward normal, - /// and optional stair base position, then append a to . + /// and optional stair base position, then append a to . /// private void TryAddExteriorDoor( - WallSide wall, WallOpening? opening, List list) + WallSide wall, WallOpening? opening, List list) { if (opening == null || opening.Type != WallOpeningType.Door) return; @@ -827,7 +853,7 @@ private void TryAddExteriorDoor( Vector3? stairBase = ComputeStairBasePosition(wall, center, inward); - list.Add(new NavMeshDoorwayInfo( + list.Add(new NavDoorwayInfo( center, opening.Width, opening.Height, inward, _config.WallThickness, stairBase)); } diff --git a/Building/InteriorNavigator.cs b/Building/InteriorNavigator.cs new file mode 100644 index 0000000..15a5657 --- /dev/null +++ b/Building/InteriorNavigator.cs @@ -0,0 +1,31 @@ +using UnityEngine; + +#if IL2CPP +using Il2CppInterop.Runtime.Injection; +#endif + +namespace S1MAPI.Building +{ + /// + /// Thin MonoBehaviour shell that forwards Unity lifecycle calls to + /// . Kept minimal so IL2CPP's + /// ClassInjector only sees simple Unity-compatible method signatures + /// (no custom parameter types → zero registration warnings). + /// + internal sealed class InteriorNavigator : MonoBehaviour + { + /// Navigation logic core, forwarded from Unity lifecycle methods. + internal InteriorNavigatorCore? _core; + + private void Update() + { + _core?.Update(); + } + + private void OnDestroy() + { + _core?.Cleanup(); + _core = null; + } + } +} diff --git a/Building/InteriorNavigatorCore.cs b/Building/InteriorNavigatorCore.cs new file mode 100644 index 0000000..def16e2 --- /dev/null +++ b/Building/InteriorNavigatorCore.cs @@ -0,0 +1,1358 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using S1MAPI.Utils; +using UnityEngine; +using UnityEngine.AI; + +#if IL2CPP +using Il2CppInterop.Runtime; +#endif + +namespace S1MAPI.Building +{ + /// + /// Core logic for NPC interior navigation using custom A* pathfinding. + /// + /// This is a plain C# class (not a MonoBehaviour) to avoid IL2CPP ClassInjector + /// warnings for methods with custom parameter types. The thin + /// MonoBehaviour shell forwards Unity lifecycle + /// calls (Update, OnDestroy) to this class. + /// + /// + internal sealed class InteriorNavigatorCore + { + #region Nested Types + + private enum NPCNavState + { + Approaching, // NavMeshAgent walking to doorway exterior + Entering, // Agent disabled, lerping through doorway inward + Inside, // Following A* path via Transform movement + Exiting, // A* path to doorway interior point + LeavingDoorway // Lerping through doorway outward + } + + private sealed class TrackedNPC + { + public Component NpcComponent; + public NPCNavState State; + public NavDoorwayInfo TargetDoorway; + public Vector3 DoorwayExteriorWorld; + public Vector3 DoorwayInteriorWorld; + public List? Path; + public int PathIndex; + public Vector3 TargetLocal; + public Transform? ChaseTarget; + public float ChaseRepathTimer; + public float Speed; + public Action? OnArrival; + public float LerpProgress; + public float LerpDuration; + public Vector3 LerpStart; + public Vector3 LerpEnd; + public Vector3? PendingExteriorDestination; // set when NPC exits to resume navigation + public float RepathTimer; // fallback re-pathfind timer for all NPCs + public float StuckTimer; // time NPC hasn't moved + public float ApproachStartTime; // Time.time when Approaching state began + + // Cached reflection results + public object? MovementRef; // NPCMovement instance + public NavMeshAgent? Agent; + public Collider? NpcCollider; // disabled while inside to prevent pushing player + } + + /// + /// Cross-platform member accessor for game types. + /// On Mono, game members are fields; on IL2CPP (Il2CppInterop), they become properties. + /// Tries property first, then falls back to field — works on both runtimes. + /// + private readonly struct MemberAccessor + { + private readonly FieldInfo? _field; + private readonly PropertyInfo? _prop; + + public bool IsValid => + _field != null || _prop != null; + + public MemberAccessor(Type type, string name, BindingFlags flags) + { + _prop = type.GetProperty(name, flags); + _field = _prop == null ? type.GetField(name, flags) : null; + } + + public object? GetValue(object target) => + _field != null ? _field.GetValue(target) : _prop?.GetValue(target); + + public void SetValue(object target, object? value) + { + if (_field != null) _field.SetValue(target, value); + else _prop?.SetValue(target, value); + } + } + + #endregion + + #region Fields + + private readonly InteriorPathGrid _grid; + private readonly IReadOnlyList _doorways; + private readonly Transform _buildingRoot; + private readonly Vector3 _roomSize; + private readonly float _foundationHeight; + + private readonly Dictionary _tracked = + new Dictionary(); + private readonly List _removeQueue = new List(); + private readonly Dictionary _lastPositions = new Dictionary(); + private float _doorwayScanTimer; + private float _approachLogTimer; + private static readonly Collider[] _scanBuffer = new Collider[32]; + + // Prevent NPC from being managed by two buildings simultaneously + private static readonly HashSet _globallyManaged = new HashSet(); + + // Static registry of all active buildings (for auto-detection patch) + private static readonly List _activeBuildings = new List(); + private static bool _patchApplied; + + // Reflection cache (resolved once) + private static bool _reflectionResolved; + private static Type? _npcMovementType; + private static MemberAccessor _agentAccessor; + private static MethodInfo? _setAgentEnabled; + private static MemberAccessor _walkSpeedAccessor; + private static MemberAccessor _runSpeedAccessor; + private static MemberAccessor _speedScaleAccessor; + private static MemberAccessor _moveSpeedMultAccessor; + private static MemberAccessor _hasDestinationAccessor; + private static MethodInfo? _originalSetDestination; + private static Harmony? _harmony; + + #endregion + + #region Constructor + + /// + /// Create and initialize the interior navigation core. + /// + public InteriorNavigatorCore( + InteriorPathGrid grid, + IReadOnlyList doorways, + Transform buildingRoot, + Vector3 roomSize, + float foundationHeight) + { + _grid = grid; + _doorways = doorways; + _buildingRoot = buildingRoot; + _roomSize = roomSize; + _foundationHeight = foundationHeight; + + ResolveReflection(); + + _activeBuildings.Add(this); + ApplyPatchIfNeeded(); + } + + #endregion + + #region Reflection & Patching + + private static void ResolveReflection() + { + if (_reflectionResolved) return; + _reflectionResolved = true; + + // On IL2CPP, Il2CppInterop prefixes game namespaces with "Il2Cpp" +#if IL2CPP + const string typeName = "Il2CppScheduleOne.NPCs.NPCMovement"; +#else + const string typeName = "ScheduleOne.NPCs.NPCMovement"; +#endif + foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies()) + { + _npcMovementType = asm.GetType(typeName); + if (_npcMovementType != null) break; + } + if (_npcMovementType == null) + { + DebugLog.Warning("[InteriorNavigator] NPCMovement type not found."); + return; + } + + // MemberAccessor tries GetProperty first (IL2CPP), falls back to GetField (Mono) + const BindingFlags pub = BindingFlags.Public | BindingFlags.Instance; + _agentAccessor = new MemberAccessor(_npcMovementType, "Agent", pub); + _setAgentEnabled = _npcMovementType.GetMethod("SetAgentEnabled", pub); + _walkSpeedAccessor = new MemberAccessor(_npcMovementType, "WalkSpeed", pub); + _runSpeedAccessor = new MemberAccessor(_npcMovementType, "RunSpeed", pub); + _speedScaleAccessor = new MemberAccessor(_npcMovementType, "MovementSpeedScale", pub); + _moveSpeedMultAccessor = new MemberAccessor(_npcMovementType, "MoveSpeedMultiplier", pub); + _hasDestinationAccessor = new MemberAccessor(_npcMovementType, "HasDestination", pub); + } + + private static void ApplyPatchIfNeeded() + { + if (_patchApplied || _npcMovementType == null) return; + _patchApplied = true; + + // Find the 4-param public SetDestination(Vector3, Action, float, float) + MethodInfo? target = null; + foreach (MethodInfo m in _npcMovementType.GetMethods(BindingFlags.Public | BindingFlags.Instance)) + { + if (m.Name != "SetDestination") continue; + ParameterInfo[] pars = m.GetParameters(); + if (pars.Length == 4 && pars[0].ParameterType == typeof(Vector3)) + { + target = m; + break; + } + } + + if (target == null) + { + DebugLog.Warning("[InteriorNavigator] Could not find SetDestination to patch."); + return; + } + + _originalSetDestination = target; + + _harmony = new Harmony("com.s1mapi.interiornavigator"); + + // Patch SetDestination — intercept all NPC destination calls + MethodInfo setDestPrefix = typeof(InteriorNavigatorCore).GetMethod( + nameof(SetDestinationPrefix), + BindingFlags.NonPublic | BindingFlags.Static)!; + _harmony.Patch(target, prefix: new HarmonyMethod(setDestPrefix)); + + // Patch UpdateDestination — prevent private 5-param SetDestination from + // overwriting our agent destination for managed NPCs. + // UpdateDestination is called from FixedUpdate and bypasses our SetDestination patch. + MethodInfo? updateDest = _npcMovementType.GetMethod("UpdateDestination", + BindingFlags.NonPublic | BindingFlags.Instance); + if (updateDest != null) + { + MethodInfo updateDestPrefix = typeof(InteriorNavigatorCore).GetMethod( + nameof(UpdateDestinationPrefix), + BindingFlags.NonPublic | BindingFlags.Static)!; + _harmony.Patch(updateDest, prefix: new HarmonyMethod(updateDestPrefix)); + DebugLog.Info("[InteriorNavigator] Patched UpdateDestination."); + } + else + { + DebugLog.Warning("[InteriorNavigator] Could not find UpdateDestination to patch."); + } + + DebugLog.Info("[InteriorNavigator] Patched NPCMovement.SetDestination for auto-interception."); + } + + #endregion + + #region Harmony Patch + + /// + /// Harmony prefix on NPCMovement.SetDestination. Automatically intercepts + /// NPCs whose destination falls inside a registered building and redirects them + /// through the custom A* interior pathfinding system. + /// + /// Always blocks the original for managed NPCs — we send the agent to the + /// doorway ourselves. The UpdateDestination patch prevents the game's FixedUpdate + /// from overwriting our agent destination. + /// + /// + private static bool SetDestinationPrefix(object __instance, Vector3 pos) + { + Component? movement = __instance as Component; + if (movement == null) return true; + + // Check if destination is inside any registered building (0.5m margin prevents + // oscillation when player is near building wall — slight position offsets won't + // cause immediate exit/re-entry cycles) + for (int i = 0; i < _activeBuildings.Count; i++) + { + InteriorNavigatorCore nav = _activeBuildings[i]; + if (nav == null || nav._buildingRoot == null) continue; + + Vector3 localPos = nav._buildingRoot.InverseTransformPoint(pos); + if (!nav.IsInsideBuilding(localPos, margin: 0.5f)) + { + // Extended: combat AI resolves targets inside the building to NavMesh + // points at the carving boundary (~0.8m outside). Catch when player is inside. + if (!nav.IsInsideBuilding(localPos, margin: 3f) || !nav.IsPlayerInside()) + continue; + } + + // Clamp near-boundary positions to interior + localPos.x = Mathf.Clamp(localPos.x, 0f, nav._roomSize.x); + localPos.z = Mathf.Clamp(localPos.z, 0f, nav._roomSize.z); + + // Detect chase: if destination is near the player, set up continuous tracking + Transform? chaseTarget = DetectChaseTarget(pos); + + // Already tracked by this building — update target, always block original + if (nav._tracked.TryGetValue(movement, out TrackedNPC? existing)) + { + existing.TargetLocal = localPos; + existing.OnArrival = null; + if (chaseTarget != null) + existing.ChaseTarget = chaseTarget; + + if (existing.State == NPCNavState.Inside) + nav.ComputePathToTarget(existing); + + // Re-send agent to doorway if still approaching — the agent's path + // may have been consumed or cleared since the initial send. + if (existing.State == NPCNavState.Approaching && + existing.Agent != null && + existing.Agent.enabled && + existing.Agent.isOnNavMesh) + { + existing.Agent.SetDestination(existing.DoorwayExteriorWorld); + } + + return false; + } + + // New NPC — register and send agent to doorway ourselves + DebugLog.Info($"[InteriorNavigator] Tracking new NPC {movement.name} " + + $"targeting local ({localPos.x:F1}, {localPos.z:F1})"); + + TrackedNPC tracked = nav.CreateTrackedNPC(movement); + tracked.TargetLocal = localPos; + tracked.ChaseTarget = chaseTarget; + tracked.ChaseRepathTimer = 0f; + tracked.OnArrival = null; + + NavDoorwayInfo door = nav.FindNearestExteriorDoorway( + nav._buildingRoot.InverseTransformPoint(movement.transform.position)); + tracked.TargetDoorway = door; + nav.ComputeDoorwayPoints(tracked, door); + tracked.State = NPCNavState.Approaching; + tracked.ApproachStartTime = Time.time; + + // Send agent to doorway exterior directly — don't let the game send it + // to the interior position (which the agent can't reach due to carving) + if (tracked.Agent != null && tracked.Agent.enabled && tracked.Agent.isOnNavMesh) + tracked.Agent.SetDestination(tracked.DoorwayExteriorWorld); + + nav._tracked[movement] = tracked; + _globallyManaged.Add(movement); + + return false; // block original — we've set the agent destination ourselves + } + + // Destination not inside any building — check if this NPC is tracked and should exit + if (_globallyManaged.Contains(movement)) + { + for (int i = 0; i < _activeBuildings.Count; i++) + { + InteriorNavigatorCore nav = _activeBuildings[i]; + if (nav == null) continue; + + if (nav._tracked.TryGetValue(movement, out TrackedNPC? data)) + { + // During Approaching: only release if destination is clearly + // far from the building. Near-building destinations (carving + // boundary points from GetRandomReachablePointNear) should + // NOT release — the NPC is still pursuing a target inside. + if (data.State == NPCNavState.Approaching) + { + Vector3 destLocal = nav._buildingRoot.InverseTransformPoint(pos); + if (!nav.IsInsideBuilding(destLocal, margin: 5f)) + { + _globallyManaged.Remove(movement); + nav._tracked.Remove(movement); + return true; // genuinely far — let original run + } + return false; // near building — keep tracking, block game + } + + // Destination is outside building — recall NPC to exit + data.PendingExteriorDestination = pos; + nav.RecallNPC(movement); + return false; // suppress original while NPC exits the building + } + } + } + + return true; // not our concern, run original + } + + /// + /// Harmony prefix on NPCMovement.UpdateDestination. Skips the method + /// entirely for managed NPCs — prevents the private 5-param SetDestination + /// (called from FixedUpdate) from overwriting our agent destination. + /// + private static bool UpdateDestinationPrefix(object __instance) + { + Component? movement = __instance as Component; + if (movement != null && _globallyManaged.Contains(movement)) + return false; // skip — we're managing this NPC's destination + return true; + } + + /// + /// Check if the destination is near any player (indicating a chase scenario). + /// Returns the nearest player's Transform if so, null otherwise. + /// Uses to support multiplayer. + /// + private static Transform? DetectChaseTarget(Vector3 destination) + { + foreach (Camera cam in Camera.allCameras) + { + Vector3 camPos = cam.transform.position; + float dx = destination.x - camPos.x; + float dz = destination.z - camPos.z; + float distSq = dx * dx + dz * dz; + + // If destination is within 3m of a player, this is a chase + if (distSq < 9f) + return cam.transform; + } + + return null; + } + + #endregion + + #region Public API + + /// + /// Send an NPC to a position inside the building. The NPC walks to the nearest + /// doorway on exterior NavMesh, enters via lerp, then follows A* path to target. + /// + public void SendNPCToPosition(Component npc, Vector3 localTarget, Action? onArrival) + { + // Already tracked by this building — update target in place + if (_tracked.TryGetValue(npc, out TrackedNPC? existing)) + { + existing.TargetLocal = localTarget; + existing.OnArrival = onArrival; + existing.ChaseTarget = null; + if (existing.State == NPCNavState.Inside) + ComputePathToTarget(existing); + return; + } + + if (_globallyManaged.Contains(npc)) + { + DebugLog.Warning("[InteriorNavigator] NPC is already managed by another building."); + return; + } + + TrackedNPC tracked = CreateTrackedNPC(npc); + tracked.TargetLocal = localTarget; + tracked.OnArrival = onArrival; + tracked.ChaseTarget = null; + + BeginApproach(tracked); + SendAgentToDoorway(tracked); + _tracked[npc] = tracked; + _globallyManaged.Add(npc); + + DebugLog.Info($"[InteriorNavigator] Sending NPC to local ({localTarget.x:F1}, {localTarget.z:F1})"); + } + + /// + /// Send an NPC to chase a moving target inside the building. + /// Re-pathfinds at . + /// + public void SendNPCToChase(Component npc, Transform target) + { + if (_globallyManaged.Contains(npc)) + { + DebugLog.Warning("[InteriorNavigator] NPC is already managed by a building."); + return; + } + + SendNPCToChaseInternal(npc, target); + } + + private void SendNPCToChaseInternal(Component npc, Transform target) + { + Vector3 targetLocal = _buildingRoot.InverseTransformPoint(target.position); + + TrackedNPC tracked = CreateTrackedNPC(npc); + tracked.TargetLocal = targetLocal; + tracked.ChaseTarget = target; + tracked.ChaseRepathTimer = 0f; + tracked.OnArrival = null; + + BeginApproach(tracked); + SendAgentToDoorway(tracked); + _tracked[npc] = tracked; + _globallyManaged.Add(npc); + + DebugLog.Info("[InteriorNavigator] NPC chasing player — continuous tracking enabled."); + } + + /// + /// Directly set the NavMeshAgent destination to the doorway exterior. + /// Used by the consumer API (SendNPCToPosition/SendNPCToChase) where the + /// game's behavior system isn't involved. The Harmony prefix path doesn't + /// need this — the game's own SetDestination handles agent routing. + /// + private static void SendAgentToDoorway(TrackedNPC data) + { + if (data.Agent != null && data.Agent.enabled && data.Agent.isOnNavMesh) + data.Agent.SetDestination(data.DoorwayExteriorWorld); + } + + /// + /// Recall an NPC from the building. If inside, begins exit via A* path to doorway. + /// If still approaching, releases immediately. + /// + public void RecallNPC(Component npc) + { + if (!_tracked.TryGetValue(npc, out TrackedNPC? data)) return; + + if (data.State == NPCNavState.Approaching) + { + // Still on exterior NavMesh — just release + ReleaseNPC(npc, data, warpToExterior: false); + } + else if (data.State != NPCNavState.Exiting && + data.State != NPCNavState.LeavingDoorway) + { + BeginExit(npc, data); + } + } + + /// Whether this navigator is currently managing the given NPC. + public bool IsTracking(Component npc) => + _tracked.ContainsKey(npc); + + /// + /// Show/hide the walkability grid visualization (green = walkable, red = blocked). + /// + public void VisualizeGrid(bool show) + { + if (show) + _grid.Visualize(); + else + _grid.DestroyVisualization(); + } + + /// + /// Diagnose why a specific grid cell is blocked. Logs details to console. + /// Cell coordinates are shown in the visualization quad names (Cell_X_Z). + /// + public void DiagnoseCell(int gx, int gz) => + _grid.DiagnoseCell(gx, gz); + + /// + /// Release all tracked NPCs. Warps those inside to the exterior and re-enables agents. + /// Called automatically on building destruction. + /// + public void ReleaseAllNPCs() + { + foreach (KeyValuePair kvp in _tracked) + { + if (kvp.Key == null) continue; + TrackedNPC data = kvp.Value; + + if (data.State != NPCNavState.Approaching) + { + // NPC is inside — warp to exterior + if (data.DoorwayExteriorWorld != Vector3.zero) + kvp.Key.transform.position = data.DoorwayExteriorWorld; + if (data.NpcCollider != null) + data.NpcCollider.enabled = true; + EnableAgent(data); + } + + _globallyManaged.Remove(kvp.Key); + } + _tracked.Clear(); + } + + #endregion + + #region Update Loop + + public void Update() + { + // Periodically scan for untracked NPCs near doorways. + // Catches NPCs whose SetDestination resolved to a NavMesh point outside + // the building (carving boundary) — the prefix can't intercept those. + _doorwayScanTimer -= Time.deltaTime; + if (_doorwayScanTimer <= 0f) + { + _doorwayScanTimer = 0.5f; + ScanForNearbyNPCs(); + } + + if (_tracked.Count == 0) return; + + _removeQueue.Clear(); + + foreach (KeyValuePair kvp in _tracked) + { + Component npc = kvp.Key; + TrackedNPC data = kvp.Value; + + if (npc == null) + { + _removeQueue.Add(npc!); + continue; + } + + switch (data.State) + { + case NPCNavState.Approaching: + UpdateApproaching(npc, data); + break; + case NPCNavState.Entering: + UpdateLerp(npc, data, onComplete: () => + { + // Check if we still need phase 2 (through doorway) + float distToInterior = Vector3.Distance( + npc.transform.position, data.DoorwayInteriorWorld); + if (distToInterior > 0.5f) + { + // Phase 1 complete — now lerp through the doorway + data.Speed = GetNPCSpeed(data); + data.LerpStart = npc.transform.position; + data.LerpEnd = data.DoorwayInteriorWorld; + float dist = Vector3.Distance(data.LerpStart, data.LerpEnd); + data.LerpDuration = Mathf.Max( + dist / Mathf.Max(data.Speed, 1f), 0.1f); + data.LerpProgress = 0f; + } + else + { + data.State = NPCNavState.Inside; + DebugLog.Info("[InteriorNavigator] NPC entered building, computing A* path..."); + ComputePathToTarget(data); + } + }); + break; + case NPCNavState.Inside: + UpdateInside(npc, data); + break; + case NPCNavState.Exiting: + UpdatePathFollow(npc, data, onComplete: () => + BeginDoorwayLeave(npc, data)); + break; + case NPCNavState.LeavingDoorway: + UpdateLerp(npc, data, onComplete: () => + ReleaseNPC(npc, data, warpToExterior: false)); + break; + } + } + + foreach (Component npc in _removeQueue) + { + _globallyManaged.Remove(npc); + _tracked.Remove(npc); + _lastPositions.Remove(npc); + } + } + + #endregion + + #region Doorway Proximity Scan + + /// + /// Detect untracked NPCs standing near doorways while the player is inside. + /// The game's combat AI resolves destinations to NavMesh carving boundary points + /// (outside the building), so the SetDestination prefix may not intercept them. + /// This scan catches those NPCs by proximity instead. + /// + private void ScanForNearbyNPCs() + { + if (_npcMovementType == null) return; + + // Only scan if any player is inside this building (multiplayer-safe) + Transform? insidePlayer = FindPlayerInside(); + if (insidePlayer == null) return; + + Vector3 playerLocal = _buildingRoot.InverseTransformPoint(insidePlayer.position); + float threshold = Constants.InteriorNav.DoorwayApproachThreshold; + + for (int d = 0; d < _doorways.Count; d++) + { + NavDoorwayInfo door = _doorways[d]; + if (door.IsInterior) continue; + + // Scan at ground level — cop stands at terrain height, not elevated floor + Vector3 scanCenter = door.Center; + scanCenter.y = door.StairBasePosition.HasValue + ? door.StairBasePosition.Value.y + : -_foundationHeight; + Vector3 doorWorld = _buildingRoot.TransformPoint(scanCenter); + int count = Physics.OverlapSphereNonAlloc( + doorWorld, threshold, _scanBuffer, Physics.AllLayers, + QueryTriggerInteraction.Collide); + + for (int c = 0; c < count; c++) + { + if (_scanBuffer[c] == null) continue; + + Component? movement = FindComponentInParent(_scanBuffer[c], _npcMovementType); + if (movement == null) continue; + if (_globallyManaged.Contains(movement)) continue; + if (_tracked.ContainsKey(movement)) continue; + + DebugLog.Info($"[InteriorNavigator] Auto-detected NPC {movement.name} near doorway, registering..."); + + TrackedNPC tracked = CreateTrackedNPC(movement); + tracked.TargetLocal = playerLocal; + tracked.ChaseTarget = insidePlayer; + tracked.ChaseRepathTimer = 0f; + tracked.OnArrival = null; + tracked.TargetDoorway = door; + ComputeDoorwayPoints(tracked, door); + + _tracked[movement] = tracked; + _globallyManaged.Add(movement); + + // NPC is already at the doorway — skip Approaching, enter immediately + BeginDoorwayEntry(movement, tracked); + } + } + } + + #endregion + + #region State: Approaching + + private void BeginApproach(TrackedNPC data) + { + // Find nearest exterior doorway to the NPC's current position + Vector3 npcLocal = _buildingRoot.InverseTransformPoint(data.NpcComponent.transform.position); + NavDoorwayInfo door = FindNearestExteriorDoorway(npcLocal); + data.TargetDoorway = door; + ComputeDoorwayPoints(data, door); + + data.State = NPCNavState.Approaching; + data.ApproachStartTime = Time.time; + + DebugLog.Info($"[InteriorNavigator] NPC approaching doorway at {data.DoorwayExteriorWorld}"); + } + + private void UpdateApproaching(Component npc, TrackedNPC data) + { + Vector3 npcPos = npc.transform.position; + float threshold = Constants.InteriorNav.DoorwayApproachThreshold; + + // Check distance to target doorway + float distXZ = HorizontalDistance(npcPos, data.DoorwayExteriorWorld); + + if (distXZ < threshold) + { + DebugLog.Info($"[InteriorNavigator] NPC reached doorway (distXZ={distXZ:F2}), entering..."); + BeginDoorwayEntry(npc, data); + return; + } + + // Check all exterior doorways — NPC may be closer to a different one + // (e.g., routed around the building by NavMesh) + for (int i = 0; i < _doorways.Count; i++) + { + NavDoorwayInfo door = _doorways[i]; + if (door.IsInterior || door == data.TargetDoorway) continue; + + Vector3 doorWorld = _buildingRoot.TransformPoint(door.Center); + float altDist = HorizontalDistance(npcPos, doorWorld); + if (altDist < threshold) + { + DebugLog.Info($"[InteriorNavigator] NPC near alternate doorway (dist={altDist:F2}), switching..."); + data.TargetDoorway = door; + ComputeDoorwayPoints(data, door); + BeginDoorwayEntry(npc, data); + return; + } + } + + // Approach timeout — if the agent has been trying for too long, force entry + // at the nearest doorway. Uses 2-phase lerp so NPC walks smoothly to the + // doorway exterior before entering (not a warp). + float elapsed = Time.time - data.ApproachStartTime; + if (elapsed > 12f) + { + Vector3 npcLocal = _buildingRoot.InverseTransformPoint(npcPos); + NavDoorwayInfo nearest = FindNearestExteriorDoorway(npcLocal); + data.TargetDoorway = nearest; + ComputeDoorwayPoints(data, nearest); + + float nearestDist = HorizontalDistance(npcPos, data.DoorwayExteriorWorld); + if (nearestDist < 12f) // Only force if within reasonable distance + { + DebugLog.Warning($"[InteriorNavigator] Approach timeout ({elapsed:F0}s), " + + $"forcing entry (dist={nearestDist:F1}m)..."); + BeginDoorwayEntry(npc, data); + return; + } + } + + if (data.Agent == null) return; + + bool pathPending = data.Agent.pathPending; + bool hasPath = data.Agent.hasPath; + float remaining = data.Agent.remainingDistance; + bool remainingValid = !float.IsInfinity(remaining) && !float.IsNaN(remaining); + + // Agent finished its path near-ish to doorway — enter + if (!pathPending && remainingValid && remaining < 0.5f && distXZ < 8f) + { + DebugLog.Info($"[InteriorNavigator] Agent path done near doorway (distXZ={distXZ:F2}), entering..."); + BeginDoorwayEntry(npc, data); + return; + } + + // Re-send agent to doorway when it has no active path + if (!pathPending && !hasPath && data.Agent.enabled && data.Agent.isOnNavMesh) + { + data.Agent.SetDestination(data.DoorwayExteriorWorld); + } + + // Periodic diagnostic logging + _approachLogTimer -= Time.deltaTime; + if (_approachLogTimer <= 0f) + { + _approachLogTimer = 3f; + DebugLog.Info($"[InteriorNavigator] Approach: distXZ={distXZ:F2}, " + + $"elapsed={elapsed:F0}s, hasPath={hasPath}, remaining={remaining:F2}"); + } + } + + private static float HorizontalDistance(Vector3 a, Vector3 b) + { + float dx = a.x - b.x; + float dz = a.z - b.z; + return Mathf.Sqrt(dx * dx + dz * dz); + } + + #endregion + + #region State: Entering / Leaving (Doorway Lerp) + + private void BeginDoorwayEntry(Component npc, TrackedNPC data) + { + // Stop the agent directly (avoid NPCMovement.Stop() which fires stale callbacks) + if (data.Agent != null) + { + data.Agent.isStopped = true; + data.Agent.ResetPath(); + } + DisableAgent(data); + + // Disable NPC collider to prevent pushing the player while inside + if (data.NpcCollider != null) + data.NpcCollider.enabled = false; + + // Clear HasDestination so game's UpdateDestination() doesn't run + // and warp the NPC back to the NavMesh surface + ClearHasDestination(data); + + data.Speed = GetNPCSpeed(data); + data.LerpStart = npc.transform.position; + + // 2-phase entry: if NPC is far from the doorway exterior, first walk to the + // exterior point (phase 1), then through the doorway (phase 2). This prevents + // wall clipping when entry triggers from an angle. + float distToExterior = Vector3.Distance(npc.transform.position, data.DoorwayExteriorWorld); + data.LerpEnd = distToExterior > 1.0f + ? data.DoorwayExteriorWorld // Phase 1: walk to exterior + : data.DoorwayInteriorWorld; // Already near exterior, go straight through + + float distance = Vector3.Distance(data.LerpStart, data.LerpEnd); + data.LerpDuration = Mathf.Max(distance / Mathf.Max(data.Speed, 1f), 0.1f); + data.LerpProgress = 0f; + data.State = NPCNavState.Entering; + } + + private void BeginDoorwayLeave(Component npc, TrackedNPC data) + { + data.Speed = GetNPCSpeed(data); + data.LerpStart = npc.transform.position; + data.LerpEnd = data.DoorwayExteriorWorld; + float distance = Vector3.Distance(data.LerpStart, data.LerpEnd); + data.LerpDuration = Mathf.Max(distance / Mathf.Max(data.Speed, 1f), 0.1f); + data.LerpProgress = 0f; + data.State = NPCNavState.LeavingDoorway; + } + + private void UpdateLerp(Component npc, TrackedNPC data, Action onComplete) + { + data.LerpProgress += Time.deltaTime / data.LerpDuration; + float t = Mathf.Clamp01(data.LerpProgress); + float smooth = t * t * (3f - 2f * t); // smoothstep + npc.transform.position = Vector3.Lerp(data.LerpStart, data.LerpEnd, smooth); + + // Face movement direction + RotateToward(npc.transform, data.LerpEnd - data.LerpStart); + + if (t >= 1f) + { + npc.transform.position = data.LerpEnd; + onComplete(); + } + } + + #endregion + + #region State: Inside + + private void UpdateInside(Component npc, TrackedNPC data) + { + Vector3 pos = npc.transform.position; + + // Chase mode: periodically re-pathfind toward moving target. + // Two-stage null check: first tests C# reference (was a target assigned?), + // second tests Unity's operator== (was the GameObject destroyed at runtime?). + if (data.ChaseTarget != null) + { + if (data.ChaseTarget == null) + { + BeginExit(npc, data); + return; + } + + Vector3 targetLocal = _buildingRoot.InverseTransformPoint(data.ChaseTarget.position); + if (!IsInsideBuilding(targetLocal)) + { + BeginExit(npc, data); + return; + } + + // Stopping distance: don't move when already close enough to target. + // Prevents oscillation from overshooting + re-pathing. + float distToTarget = Vector3.Distance(pos, data.ChaseTarget.position); + if (distToTarget < Constants.InteriorNav.DestinationArrivalThreshold) + { + // Close enough — just face the target and idle + RotateToward(npc.transform, data.ChaseTarget.position - pos); + data.Path = null; + data.ChaseRepathTimer = Constants.InteriorNav.ChaseRepathInterval; + return; + } + + data.ChaseRepathTimer -= Time.deltaTime; + if (data.ChaseRepathTimer <= 0f) + { + data.ChaseRepathTimer = Constants.InteriorNav.ChaseRepathInterval; + data.TargetLocal = targetLocal; + ComputePathToTarget(data); + } + } + + // Fallback: re-pathfind periodically even without chase target. + // This handles the case where the game stops calling SetDestination + // after we cleared HasDestination (NPC would otherwise stand forever). + data.RepathTimer -= Time.deltaTime; + if (data.RepathTimer <= 0f) + { + data.RepathTimer = 0.5f; + if (data.Path == null || data.PathIndex >= data.Path.Count) + { + ComputePathToTarget(data); + if (data.Path != null) + DebugLog.Info($"[InteriorNavigator] Fallback re-path found {data.Path.Count} waypoints, speed={data.Speed:F1}"); + } + } + + // Stuck detection — if NPC hasn't moved for 2s, recompute path + if (data.Path != null && data.PathIndex < data.Path.Count) + { + float movedSq = (pos - _lastPositions.GetValueOrDefault(npc, pos)).sqrMagnitude; + if (movedSq < 0.01f) // less than 0.1m moved + { + data.StuckTimer += Time.deltaTime; + if (data.StuckTimer > 1.5f) + { + data.StuckTimer = 0f; + DebugLog.Warning($"[InteriorNavigator] NPC stuck, recomputing path. speed={data.Speed:F1}"); + ComputePathToTarget(data); + } + } + else + { + data.StuckTimer = 0f; + } + } + _lastPositions[npc] = pos; + + UpdatePathFollow(npc, data, onComplete: () => + { + if (data.ChaseTarget == null) + { + // Directed mode: arrived at destination + data.OnArrival?.Invoke(); + data.OnArrival = null; + // NPC stays until RecallNPC + } + // Chase mode: will re-pathfind next interval + }); + } + + #endregion + + #region State: Exiting + + private void BeginExit(Component npc, TrackedNPC data) + { + Vector3 currentLocal = _buildingRoot.InverseTransformPoint(npc.transform.position); + NavDoorwayInfo door = FindNearestExteriorDoorway(currentLocal); + data.TargetDoorway = door; + ComputeDoorwayPoints(data, door); + + // Pathfind to doorway interior point + data.Path = _grid.FindPath(currentLocal, _buildingRoot.InverseTransformPoint(data.DoorwayInteriorWorld)); + data.PathIndex = 0; + data.State = NPCNavState.Exiting; + data.ChaseTarget = null; + } + + #endregion + + #region Path Following + + private void UpdatePathFollow(Component npc, TrackedNPC data, Action onComplete) + { + if (data.Path == null || data.PathIndex >= data.Path.Count) + { + onComplete(); + return; + } + + // Refresh speed each frame (catches walk→run transitions in chase mode) + data.Speed = GetNPCSpeed(data); + + Vector3 target = data.Path[data.PathIndex]; + Vector3 pos = npc.transform.position; + + // Move at floor height + float floorY = _buildingRoot.TransformPoint(Vector3.zero).y; + target.y = floorY; + Vector3 newPos = Vector3.MoveTowards(pos, target, data.Speed * Time.deltaTime); + newPos.y = floorY; + npc.transform.position = newPos; + + // Face movement direction + RotateToward(npc.transform, target - pos); + + // Check waypoint arrival — advance through multiple waypoints per frame + // if speed is high enough (prevents slow cell-by-cell crawl) + float distSq = (newPos.x - target.x) * (newPos.x - target.x) + + (newPos.z - target.z) * (newPos.z - target.z); + + float threshold = (data.PathIndex == data.Path.Count - 1) + ? Constants.InteriorNav.DestinationArrivalThreshold + : Constants.InteriorNav.WaypointArrivalThreshold; + + if (distSq < threshold * threshold) + { + data.PathIndex++; + // Skip ahead through close waypoints in the same frame + while (data.PathIndex < data.Path.Count - 1) + { + Vector3 next = data.Path[data.PathIndex]; + next.y = floorY; + float nextDistSq = (newPos.x - next.x) * (newPos.x - next.x) + + (newPos.z - next.z) * (newPos.z - next.z); + if (nextDistSq < threshold * threshold) + data.PathIndex++; + else + break; + } + } + } + + private void ComputePathToTarget(TrackedNPC data) + { + Vector3 currentLocal = _buildingRoot.InverseTransformPoint( + data.NpcComponent.transform.position); + data.Path = _grid.FindPath(currentLocal, data.TargetLocal); + data.PathIndex = 0; + + if (data.Path == null) + { + DebugLog.Warning("[InteriorNavigator] No path found to target " + + $"({data.TargetLocal.x:F1}, {data.TargetLocal.z:F1})"); + } + } + + #endregion + + #region Helpers + + /// + /// Cross-platform GetComponent by . + /// IL2CPP requires Il2CppSystem.Type; this converts automatically. + /// + private static Component? FindComponent(Component target, Type type) + { +#if IL2CPP + return target.GetComponent(Il2CppType.From(type)); +#else + return target.GetComponent(type); +#endif + } + + /// + /// Cross-platform GetComponentInChildren by . + /// + private static Component? FindComponentInChildren(Component target, Type type) + { +#if IL2CPP + return target.GetComponentInChildren(Il2CppType.From(type)); +#else + return target.GetComponentInChildren(type); +#endif + } + + /// + /// Cross-platform GetComponentInParent by . + /// + private static Component? FindComponentInParent(Component target, Type type) + { +#if IL2CPP + return target.GetComponentInParent(Il2CppType.From(type)); +#else + return target.GetComponentInParent(type); +#endif + } + + private void RotateToward(Transform t, Vector3 direction) + { + direction.y = 0f; + if (direction.sqrMagnitude < 0.001f) return; + + Quaternion targetRot = Quaternion.LookRotation(direction.normalized, Vector3.up); + t.rotation = Quaternion.RotateTowards( + t.rotation, targetRot, + Constants.InteriorNav.RotationSpeed * Time.deltaTime); + } + + private NavDoorwayInfo FindNearestExteriorDoorway(Vector3 localPos) + { + NavDoorwayInfo? best = null; + float bestDist = float.MaxValue; + foreach (NavDoorwayInfo d in _doorways) + { + if (d.IsInterior) continue; + float dist = Vector3.Distance(localPos, d.Center); + if (dist < bestDist) { bestDist = dist; best = d; } + } + return best!; + } + + private void ComputeDoorwayPoints(TrackedNPC data, NavDoorwayInfo door) + { + // Exterior point: well outside the carving zone on surviving NavMesh. + // Must be far enough that the NavMesh agent can path to it without + // routing along the carving boundary (which causes corner-sticking). + float extOffset = door.WallThickness / 2f + 2.5f; + Vector3 extLocal = door.Center - door.InwardNormal * extOffset; + extLocal.y = door.StairBasePosition.HasValue + ? door.StairBasePosition.Value.y + : -_foundationHeight; + + Vector3 extWorld = _buildingRoot.TransformPoint(extLocal); + + // Don't snap to NavMesh — SamplePosition can move the point around building + // corners, causing the agent to route along the carving boundary and get stuck. + // The agent's SetDestination internally snaps to the nearest reachable NavMesh. + + // Interior point: inside the wall on the floor + float intOffset = door.WallThickness / 2f + 0.3f; + Vector3 intLocal = door.Center + door.InwardNormal * intOffset; + intLocal.y = 0f; + + data.DoorwayExteriorWorld = extWorld; + data.DoorwayInteriorWorld = _buildingRoot.TransformPoint(intLocal); + } + + private bool IsInsideBuilding(Vector3 localPos, float margin = 0f) + { + return localPos.x >= -margin && localPos.x <= _roomSize.x + margin && + localPos.z >= -margin && localPos.z <= _roomSize.z + margin; + } + + /// + /// Check if any player is inside this building. + /// Uses to support multiplayer. + /// + private bool IsPlayerInside() => + FindPlayerInside() != null; + + /// + /// Find the first player transform inside this building, or null if none. + /// Uses to support multiplayer — each player has a camera. + /// + private Transform? FindPlayerInside() + { + foreach (Camera cam in Camera.allCameras) + { + Vector3 playerLocal = _buildingRoot.InverseTransformPoint(cam.transform.position); + if (IsInsideBuilding(playerLocal, margin: 1f)) + return cam.transform; + } + return null; + } + + private TrackedNPC CreateTrackedNPC(Component npc) + { + var tracked = new TrackedNPC { NpcComponent = npc }; + + // Resolve NPCMovement and NavMeshAgent via reflection + if (_npcMovementType != null) + { + Component? movement = FindComponent(npc, _npcMovementType); + if (movement == null) + movement = FindComponentInChildren(npc, _npcMovementType); + if (movement == null) + movement = FindComponentInParent(npc, _npcMovementType); + + if (movement != null) + { + tracked.MovementRef = movement; + if (_agentAccessor.IsValid) + tracked.Agent = _agentAccessor.GetValue(movement) as NavMeshAgent; + } + } + + // Find the NPC's physics collider (CapsuleCollider on a child GameObject). + // Disabled while inside to prevent NPCs from pushing the player. + tracked.NpcCollider = npc.GetComponentInChildren(); + + tracked.Speed = GetNPCSpeed(tracked); + return tracked; + } + + private float GetNPCSpeed(TrackedNPC data) + { + if (data.MovementRef == null) return 3.5f; + + float walkSpeed = 1.8f; + float runSpeed = 7f; + float scale = 0f; + float multiplier = 1f; + + try + { + if (_walkSpeedAccessor.IsValid) + { + object? val = _walkSpeedAccessor.GetValue(data.MovementRef); + if (val is float f) walkSpeed = f; + } + if (_runSpeedAccessor.IsValid) + { + object? val = _runSpeedAccessor.GetValue(data.MovementRef); + if (val is float f) runSpeed = f; + } + if (_speedScaleAccessor.IsValid) + { + object? val = _speedScaleAccessor.GetValue(data.MovementRef); + if (val is float f) scale = f; + } + if (_moveSpeedMultAccessor.IsValid) + { + object? val = _moveSpeedMultAccessor.GetValue(data.MovementRef); + if (val is float f) multiplier = f; + } + } + catch (Exception ex) + { + DebugLog.Warning($"[InteriorNavigator] Failed to read NPC speed via reflection: {ex.Message}"); + } + + return Mathf.Lerp(walkSpeed, runSpeed, scale) * multiplier; + } + + private void ReleaseNPC(Component npc, TrackedNPC data, bool warpToExterior) + { + if (warpToExterior && data.DoorwayExteriorWorld != Vector3.zero) + npc.transform.position = data.DoorwayExteriorWorld; + + // Re-enable collider before releasing back to game control + if (data.NpcCollider != null) + data.NpcCollider.enabled = true; + + EnableAgent(data); + + // Must remove from global set BEFORE invoking SetDestination + // so the Harmony prefix lets the call through to the original method. + _globallyManaged.Remove(npc); + _removeQueue.Add(npc); + + // Resume navigation to pending exterior destination if one was set + // (e.g. game called SetDestination(outside) while NPC was inside) + if (data.PendingExteriorDestination.HasValue && + data.MovementRef != null && + _originalSetDestination != null) + { + try + { + // Parameters: (Vector3 destination, Action callback, float walkSpeedMult, float runSpeedMult) + _originalSetDestination.Invoke( + data.MovementRef, + new object?[] { data.PendingExteriorDestination.Value, null, 1f, 1f }); + } + catch (Exception ex) + { + DebugLog.Warning($"[InteriorNavigator] Failed to set pending destination: {ex.Message}"); + } + } + + DebugLog.Info("[InteriorNavigator] NPC released from building."); + } + + private void DisableAgent(TrackedNPC data) + { + if (data.MovementRef != null && _setAgentEnabled != null) + { + try { _setAgentEnabled.Invoke(data.MovementRef, new object[] { false }); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] DisableAgent failed: {ex.Message}"); } + } + } + + private void ClearHasDestination(TrackedNPC data) + { + if (data.MovementRef != null && _hasDestinationAccessor.IsValid) + { + try { _hasDestinationAccessor.SetValue(data.MovementRef, false); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] ClearHasDestination failed: {ex.Message}"); } + } + } + + private void EnableAgent(TrackedNPC data) + { + if (data.MovementRef != null && _setAgentEnabled != null) + { + try { _setAgentEnabled.Invoke(data.MovementRef, new object[] { true }); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] EnableAgent failed: {ex.Message}"); } + } + } + + #endregion + + #region Cleanup + + /// + /// Clean up this navigator instance. Unregisters from the active buildings list, + /// releases all NPCs, and unpatches Harmony when no buildings remain. + /// Safe to call multiple times. + /// + public void Cleanup() + { + _activeBuildings.Remove(this); + ReleaseAllNPCs(); + + // Unpatch when no buildings remain + if (_activeBuildings.Count == 0 && _harmony != null) + { + _harmony.UnpatchSelf(); + _harmony = null; + _patchApplied = false; + DebugLog.Info("[InteriorNavigator] Unpatched NPCMovement.SetDestination (no active buildings)."); + } + } + + #endregion + } +} diff --git a/Building/InteriorPathGrid.cs b/Building/InteriorPathGrid.cs new file mode 100644 index 0000000..7909dfa --- /dev/null +++ b/Building/InteriorPathGrid.cs @@ -0,0 +1,737 @@ +using System; +using System.Collections.Generic; +using S1MAPI.Utils; +using UnityEngine; + +namespace S1MAPI.Building +{ + /// + /// 2D walkability grid with A* pathfinding for building interiors. + /// Generated from building layout data (room size, walls, doorways). + /// All positions are in local building coordinates (Y=0 is floor level). + /// + internal sealed class InteriorPathGrid + { + private readonly float _cellSize; + private readonly int _gridWidth; + private readonly int _gridDepth; + private readonly bool[] _walkable; + private readonly Transform _buildingRoot; + private readonly Vector3 _roomSize; + private readonly IReadOnlyList _doorways; + + /// + /// Create a walkability grid for the given building layout. + /// Marks cells as unwalkable for walls, doorway openings, and physics colliders. + /// + /// Root transform of the building. + /// Room dimensions in local coordinates. + /// Doorway positions and dimensions. + public InteriorPathGrid( + Transform buildingRoot, + Vector3 roomSize, + IReadOnlyList doorways) + { + _buildingRoot = buildingRoot; + _roomSize = roomSize; + _doorways = doorways; + _cellSize = BuildingUtilities.ComputeGridCellSize(roomSize.x, roomSize.z); + + // Grid covers exactly the room — no extension through walls. + // NPCs lerp through the wall zone via InteriorNavigator; the A* path + // only needs to reach doorway-edge cells inside the room boundary. + _gridWidth = Mathf.CeilToInt(roomSize.x / _cellSize); + _gridDepth = Mathf.CeilToInt(roomSize.z / _cellSize); + _walkable = new bool[_gridWidth * _gridDepth]; + + GenerateGrid(); + } + + /// Computed grid cell size in meters. + public float CellSize => + _cellSize; + + /// + /// Regenerate the walkability grid (e.g., after furniture placement changes). + /// + public void Regenerate() + { + GenerateGrid(); + } + + /// + /// Find a path from startLocal to endLocal in local building coordinates. + /// Returns world-space waypoints, or null if no path exists. + /// + public List? FindPath(Vector3 startLocal, Vector3 endLocal) + { + int sx = LocalToGridX(startLocal.x); + int sz = LocalToGridZ(startLocal.z); + int ex = LocalToGridX(endLocal.x); + int ez = LocalToGridZ(endLocal.z); + + // Clamp to grid bounds + sx = Mathf.Clamp(sx, 0, _gridWidth - 1); + sz = Mathf.Clamp(sz, 0, _gridDepth - 1); + ex = Mathf.Clamp(ex, 0, _gridWidth - 1); + ez = Mathf.Clamp(ez, 0, _gridDepth - 1); + + // Snap to nearest walkable cell if start/end are unwalkable + if (!IsWalkableCell(sx, sz)) + FindNearestWalkableCell(ref sx, ref sz); + if (!IsWalkableCell(ex, ez)) + FindNearestWalkableCell(ref ex, ref ez); + + if (!IsWalkableCell(sx, sz) || !IsWalkableCell(ex, ez)) + return null; + + // A* search + List? path = AStar(sx, sz, ex, ez); + if (path == null) return null; + + // Convert grid path to world-space waypoints + var worldPath = new List(path.Count); + foreach (var cell in path) + { + Vector3 local = GridToLocal(cell.x, cell.y); + worldPath.Add(_buildingRoot.TransformPoint(local)); + } + + // Smooth: skip intermediate waypoints when line-of-sight exists + SmoothPath(worldPath); + + return worldPath; + } + + /// + /// Check if a local position is on a walkable cell. + /// + public bool IsWalkable(Vector3 localPos) + { + int gx = LocalToGridX(localPos.x); + int gz = LocalToGridZ(localPos.z); + return IsWalkableCell(gx, gz); + } + + /// + /// Get the nearest walkable cell center in local coordinates. + /// + public Vector3 NearestWalkableCell(Vector3 localPos) + { + int gx = LocalToGridX(localPos.x); + int gz = LocalToGridZ(localPos.z); + FindNearestWalkableCell(ref gx, ref gz); + return GridToLocal(gx, gz); + } + + #region Grid Generation + + private void GenerateGrid() + { + float wallMargin = Constants.InteriorNav.WallMargin; + + // Start all cells walkable + for (int i = 0; i < _walkable.Length; i++) + _walkable[i] = true; + + // Mark wall margin zone as unwalkable. + // Cells near the room edge are blocked; MarkDoorwayOpenings() + // carves paths through at doorway locations. + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + Vector3 local = GridToLocal(gx, gz); + + if (local.x < wallMargin || local.x > _roomSize.x - wallMargin || + local.z < wallMargin || local.z > _roomSize.z - wallMargin) + { + _walkable[gz * _gridWidth + gx] = false; + } + } + } + + // Mark interior wall cells as unwalkable (with doorway openings) + MarkInteriorWalls(wallMargin); + + // Mark cells blocked by physics colliders (furniture, etc.) + MarkPhysicsObstacles(); + + // Re-open doorway cells LAST — door panel colliders must not block + // the walkable path through doorways. + MarkDoorwayOpenings(); + + int walkableCount = 0; + for (int i = 0; i < _walkable.Length; i++) + if (_walkable[i]) walkableCount++; + + DebugLog.Info($"[InteriorPathGrid] Generated {_gridWidth}x{_gridDepth} grid " + + $"({walkableCount}/{_walkable.Length} walkable), cellSize={_cellSize:F2}m"); + } + + private void MarkInteriorWalls(float margin) + { + foreach (var door in _doorways) + { + if (!door.IsInterior) continue; + + // Determine wall line from doorway's InwardNormal and Center. + // Interior walls run perpendicular to the InwardNormal. + Vector3 normal = door.InwardNormal; + bool isXWall = Mathf.Abs(normal.z) > Mathf.Abs(normal.x); // wall runs along X + float wallPos = isXWall ? door.Center.z : door.Center.x; + float doorCenter = isXWall ? door.Center.x : door.Center.z; + float halfWidth = door.Width / 2f; + + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + Vector3 local = GridToLocal(gx, gz); + + float perpDist = isXWall + ? Mathf.Abs(local.z - wallPos) + : Mathf.Abs(local.x - wallPos); + + if (perpDist > margin) continue; + + // Check if cell is within the doorway opening + float alongWall = isXWall ? local.x : local.z; + if (Mathf.Abs(alongWall - doorCenter) < halfWidth + 0.1f) continue; + + _walkable[gz * _gridWidth + gx] = false; + } + } + } + } + + private void MarkDoorwayOpenings() + { + foreach (var door in _doorways) + { + Vector3 center = door.Center; + float halfWidth = door.Width / 2f; + Vector3 normal = door.InwardNormal; + + // Open cells in a rectangle covering the doorway width × wall thickness. + // For exterior doorways this extends through the wall zone into the + // extended grid area so NPCs can path all the way through. + bool isXDoor = Mathf.Abs(normal.z) > Mathf.Abs(normal.x); + + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + Vector3 local = GridToLocal(gx, gz); + + float perpDist, alongDist; + if (isXDoor) + { + perpDist = Mathf.Abs(local.z - center.z); + alongDist = Mathf.Abs(local.x - center.x); + } + else + { + perpDist = Mathf.Abs(local.x - center.x); + alongDist = Mathf.Abs(local.z - center.z); + } + + // Half-cell buffer on width ensures cells whose center is + // at the doorway edge get opened (strict < would miss them). + if (perpDist < door.WallThickness / 2f + _cellSize && + alongDist < halfWidth + _cellSize * 0.5f) + { + _walkable[gz * _gridWidth + gx] = true; + } + } + } + } + } + + private void MarkPhysicsObstacles() + { + // Short box at walking height — avoids detecting ceiling/roof colliders + // which are building children and would mark every cell unwalkable. + float probeHeight = 0.5f; + Vector3 halfExtents = new Vector3( + _cellSize / 2f * 0.8f, + probeHeight, + _cellSize / 2f * 0.8f); + + Quaternion rotation = _buildingRoot.rotation; + + // Collect structural colliders to exclude (floor, ceiling, walls, foundation, stairs, roof) + var excludedColliders = new HashSet(); + CollectStructuralColliders(_buildingRoot, excludedColliders); + + // Cache living entity roots (players/NPCs with Animators) to skip + var livingRoots = new HashSet(); + var staticRoots = new HashSet(); + + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + if (!_walkable[gz * _gridWidth + gx]) continue; + + Vector3 cellLocal = GridToLocal(gx, gz); + // Center probe at walking height (0.5m above floor) + Vector3 localCenter = new Vector3(cellLocal.x, probeHeight, cellLocal.z); + Vector3 worldCenter = _buildingRoot.TransformPoint(localCenter); + + Collider[] hits = Physics.OverlapBox(worldCenter, halfExtents, rotation); + foreach (Collider hit in hits) + { + if (hit.isTrigger) continue; + if (excludedColliders.Contains(hit)) continue; + + // Skip living entities (players/NPCs) + if (BuildingUtilities.IsLivingEntity(hit.transform, livingRoots, staticRoots)) + continue; + + // Only block for colliders whose center is inside the building + // footprint. This allows external furniture (e.g. MeshVault) placed + // inside to block, while filtering world objects (trees, lights) + // whose colliders bleed in from outside. + Vector3 colliderLocal = _buildingRoot.InverseTransformPoint(hit.bounds.center); + if (colliderLocal.x < -0.5f || colliderLocal.x > _roomSize.x + 0.5f || + colliderLocal.z < -0.5f || colliderLocal.z > _roomSize.z + 0.5f) + continue; + + _walkable[gz * _gridWidth + gx] = false; + break; + } + } + } + } + + /// + /// Collect colliders from known structural folders (Floor, Walls, Ceiling, + /// Foundation, Stairs, Roof) so they are excluded from physics obstacle detection. + /// + private static void CollectStructuralColliders(Transform root, HashSet excluded) + { + string[] structuralNames = { + "Floor", "Ceiling", "Walls", "ExteriorWalls", "InteriorWalls", + Constants.Spatial.FoundationFolderName, + Constants.Spatial.StairsFolderName, + "Roof", "Parapet", "Molding", "WindowFrames", "DoorFrames" + }; + + foreach (string name in structuralNames) + { + Transform? folder = root.Find(name); + if (folder == null) continue; + foreach (Collider c in folder.GetComponentsInChildren()) + excluded.Add(c); + } + + // Also exclude colliders directly on the root itself + Collider? rootCollider = root.GetComponent(); + if (rootCollider != null) excluded.Add(rootCollider); + } + + #endregion + + #region A* Pathfinding + + private static readonly int[] DirectionX = { 0, 1, 1, 1, 0, -1, -1, -1 }; + private static readonly int[] DirectionZ = { 1, 1, 0, -1, -1, -1, 0, 1 }; + private static readonly float[] MoveCost = { 1f, 1.414f, 1f, 1.414f, 1f, 1.414f, 1f, 1.414f }; + + private List? AStar(int sx, int sz, int ex, int ez) + { + if (sx == ex && sz == ez) + return new List { new Vector2Int(sx, sz) }; + + int totalCells = _gridWidth * _gridDepth; + float[] gScore = new float[totalCells]; + float[] fScore = new float[totalCells]; + int[] cameFrom = new int[totalCells]; + bool[] closed = new bool[totalCells]; + + for (int i = 0; i < totalCells; i++) + { + gScore[i] = float.MaxValue; + fScore[i] = float.MaxValue; + cameFrom[i] = -1; + } + + int startIdx = sz * _gridWidth + sx; + int endIdx = ez * _gridWidth + ex; + gScore[startIdx] = 0f; + fScore[startIdx] = Heuristic(sx, sz, ex, ez); + + // Simple priority queue using sorted list (fine for few hundred cells) + var open = new SortedList(new DuplicateKeyComparer()); + open.Add(fScore[startIdx], startIdx); + + while (open.Count > 0) + { + int currentIdx = open.Values[0]; + open.RemoveAt(0); + + if (currentIdx == endIdx) + return ReconstructPath(cameFrom, currentIdx); + + if (closed[currentIdx]) continue; + closed[currentIdx] = true; + + int cx = currentIdx % _gridWidth; + int cz = currentIdx / _gridWidth; + + for (int d = 0; d < 8; d++) + { + int nx = cx + DirectionX[d]; + int nz = cz + DirectionZ[d]; + + if (nx < 0 || nx >= _gridWidth || nz < 0 || nz >= _gridDepth) continue; + + int neighborIdx = nz * _gridWidth + nx; + if (closed[neighborIdx] || !_walkable[neighborIdx]) continue; + + // Prevent diagonal corner-cutting through walls + if (d % 2 == 1) // diagonal + { + int adj1 = cz * _gridWidth + nx; // same z, neighbor x + int adj2 = nz * _gridWidth + cx; // neighbor z, same x + if (!_walkable[adj1] || !_walkable[adj2]) continue; + } + + float tentG = gScore[currentIdx] + MoveCost[d]; + if (tentG < gScore[neighborIdx]) + { + cameFrom[neighborIdx] = currentIdx; + gScore[neighborIdx] = tentG; + fScore[neighborIdx] = tentG + Heuristic(nx, nz, ex, ez); + open.Add(fScore[neighborIdx], neighborIdx); + } + } + } + + return null; // no path + } + + private float Heuristic(int ax, int az, int bx, int bz) + { + float dx = ax - bx; + float dz = az - bz; + return Mathf.Sqrt(dx * dx + dz * dz); + } + + private List ReconstructPath(int[] cameFrom, int current) + { + var path = new List(); + while (current != -1) + { + int x = current % _gridWidth; + int z = current / _gridWidth; + path.Add(new Vector2Int(x, z)); + current = cameFrom[current]; + } + path.Reverse(); + return path; + } + + /// + /// Comparer that allows duplicate keys in SortedList. + /// + private sealed class DuplicateKeyComparer : IComparer + { + public int Compare(float x, float y) + { + int result = x.CompareTo(y); + return result == 0 ? 1 : result; // never return 0 — treat equal as greater + } + } + + #endregion + + #region Path Smoothing + + private void SmoothPath(List worldPath) + { + if (worldPath.Count <= 2) return; + + int i = 0; + while (i < worldPath.Count - 2) + { + // Try to skip intermediate waypoints via line-of-sight + int farthest = i + 1; + for (int j = worldPath.Count - 1; j > i + 1; j--) + { + if (HasLineOfSight(worldPath[i], worldPath[j])) + { + farthest = j; + break; + } + } + + // Remove all waypoints between i and farthest + if (farthest > i + 1) + { + worldPath.RemoveRange(i + 1, farthest - i - 1); + } + + i++; + } + } + + private bool HasLineOfSight(Vector3 worldA, Vector3 worldB) + { + // Check walkability on the grid along the line from A to B + Vector3 localA = _buildingRoot.InverseTransformPoint(worldA); + Vector3 localB = _buildingRoot.InverseTransformPoint(worldB); + + int ax = LocalToGridX(localA.x); + int az = LocalToGridZ(localA.z); + int bx = LocalToGridX(localB.x); + int bz = LocalToGridZ(localB.z); + + // Bresenham-like line walk + int dx = Mathf.Abs(bx - ax); + int dz = Mathf.Abs(bz - az); + int sx = ax < bx ? 1 : -1; + int sz = az < bz ? 1 : -1; + int err = dx - dz; + + int x = ax, z = az; + while (true) + { + if (!IsWalkableCell(x, z)) return false; + if (x == bx && z == bz) break; + + int e2 = 2 * err; + if (e2 > -dz) { err -= dz; x += sx; } + if (e2 < dx) { err += dx; z += sz; } + } + + return true; + } + + #endregion + + #region Coordinate Conversion + + // Grid origin is at (0, 0) in local building coords — aligned with room corner. + private int LocalToGridX(float localX) => + Mathf.Clamp(Mathf.FloorToInt(localX / _cellSize), 0, _gridWidth - 1); + + private int LocalToGridZ(float localZ) => + Mathf.Clamp(Mathf.FloorToInt(localZ / _cellSize), 0, _gridDepth - 1); + + private Vector3 GridToLocal(int gx, int gz) => + new Vector3( + (gx + 0.5f) * _cellSize, + 0f, + (gz + 0.5f) * _cellSize); + + private bool IsWalkableCell(int gx, int gz) + { + if (gx < 0 || gx >= _gridWidth || gz < 0 || gz >= _gridDepth) + return false; + return _walkable[gz * _gridWidth + gx]; + } + + private void FindNearestWalkableCell(ref int gx, ref int gz) + { + // BFS outward from (gx, gz) to find nearest walkable cell + int bestX = gx, bestZ = gz; + float bestDist = float.MaxValue; + int searchRadius = Mathf.Max(_gridWidth, _gridDepth); + + for (int r = 1; r <= searchRadius; r++) + { + bool found = false; + for (int dx = -r; dx <= r; dx++) + { + for (int dz = -r; dz <= r; dz++) + { + if (Mathf.Abs(dx) != r && Mathf.Abs(dz) != r) continue; // only ring + int nx = gx + dx; + int nz = gz + dz; + if (IsWalkableCell(nx, nz)) + { + float dist = dx * dx + dz * dz; + if (dist < bestDist) + { + bestDist = dist; + bestX = nx; + bestZ = nz; + found = true; + } + } + } + } + if (found) break; + } + + gx = bestX; + gz = bestZ; + } + + #endregion + + #region Visualization + + private GameObject? _vizRoot; + + /// + /// Create or refresh in-game visualization of the walkability grid. + /// Green = walkable, red = blocked. Call again to refresh after Regenerate(). + /// + public void Visualize() + { + DestroyVisualization(); + + _vizRoot = new GameObject("[S1MAPI] PathGrid Visualizer"); + _vizRoot.transform.SetParent(_buildingRoot, worldPositionStays: false); + _vizRoot.transform.localPosition = Vector3.zero; + _vizRoot.transform.localRotation = Quaternion.identity; + + // Create shared materials + Material walkableMat = CreateFlatMaterial(new Color(0f, 1f, 0f, 0.35f)); + Material blockedMat = CreateFlatMaterial(new Color(1f, 0f, 0f, 0.35f)); + + float pad = 0.05f; // small gap between cells + float quadSize = _cellSize - pad * 2f; + + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + bool walkable = _walkable[gz * _gridWidth + gx]; + Vector3 localCenter = GridToLocal(gx, gz); + localCenter.y = 0.02f; // slightly above floor + + GameObject quad = GameObject.CreatePrimitive(PrimitiveType.Quad); + quad.name = $"Cell_{gx}_{gz}"; + quad.transform.SetParent(_vizRoot.transform, worldPositionStays: false); + quad.transform.localPosition = localCenter; + quad.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); // face up + quad.transform.localScale = new Vector3(quadSize, quadSize, 1f); + + // Remove collider so it doesn't interfere with gameplay + var col = quad.GetComponent(); + if (col != null) UnityEngine.Object.Destroy(col); + + var renderer = quad.GetComponent(); + if (renderer != null) + renderer.sharedMaterial = walkable ? walkableMat : blockedMat; + } + } + + DebugLog.Info($"[InteriorPathGrid] Visualization created: {_gridWidth}x{_gridDepth} cells"); + } + + /// + /// Remove the visualization markers. + /// + public void DestroyVisualization() + { + if (_vizRoot != null) + { + UnityEngine.Object.Destroy(_vizRoot); + _vizRoot = null; + } + } + + /// + /// Diagnose why a specific cell is blocked. Logs the reason (wall margin, + /// interior wall, physics collider name/path) to the console. + /// + /// Grid X coordinate (shown in Cell_X_Z name) + /// Grid Z coordinate (shown in Cell_X_Z name) + public void DiagnoseCell(int gx, int gz) + { + if (gx < 0 || gx >= _gridWidth || gz < 0 || gz >= _gridDepth) + { + DebugLog.Warning($"[PathGrid] Cell ({gx},{gz}) is out of bounds (grid is {_gridWidth}x{_gridDepth})"); + return; + } + + bool walkable = _walkable[gz * _gridWidth + gx]; + Vector3 localPos = GridToLocal(gx, gz); + Vector3 worldPos = _buildingRoot.TransformPoint(localPos); + + DebugLog.Info($"[PathGrid] Cell ({gx},{gz}): local=({localPos.x:F2},{localPos.z:F2}), world=({worldPos.x:F2},{worldPos.y:F2},{worldPos.z:F2}), walkable={walkable}"); + + if (walkable) return; + + // Check: exterior wall margin + float wallMargin = Constants.InteriorNav.WallMargin; + if (localPos.x < wallMargin || localPos.x > _roomSize.x - wallMargin || + localPos.z < wallMargin || localPos.z > _roomSize.z - wallMargin) + { + DebugLog.Info($" -> Blocked by EXTERIOR WALL margin (margin={wallMargin:F2}, roomSize=({_roomSize.x:F2},{_roomSize.z:F2}))"); + } + + // Check: interior wall + foreach (var door in _doorways) + { + if (!door.IsInterior) continue; + Vector3 normal = door.InwardNormal; + bool isXWall = Mathf.Abs(normal.z) > Mathf.Abs(normal.x); + float wallPos = isXWall ? door.Center.z : door.Center.x; + float perpDist = isXWall ? Mathf.Abs(localPos.z - wallPos) : Mathf.Abs(localPos.x - wallPos); + if (perpDist <= wallMargin) + { + float alongWall = isXWall ? localPos.x : localPos.z; + float doorCenter = isXWall ? door.Center.x : door.Center.z; + if (Mathf.Abs(alongWall - doorCenter) >= door.Width / 2f + 0.1f) + { + DebugLog.Info($" -> Blocked by INTERIOR WALL at {(isXWall ? "Z" : "X")}={wallPos:F2} (perpDist={perpDist:F2})"); + } + } + } + + // Check: physics collider + float probeHeight = 0.5f; + Vector3 halfExtents = new Vector3(_cellSize / 2f * 0.8f, probeHeight, _cellSize / 2f * 0.8f); + Vector3 probeCenter = _buildingRoot.TransformPoint(new Vector3(localPos.x, probeHeight, localPos.z)); + Collider[] hits = Physics.OverlapBox(probeCenter, halfExtents, _buildingRoot.rotation); + + var excludedColliders = new HashSet(); + CollectStructuralColliders(_buildingRoot, excludedColliders); + + foreach (Collider hit in hits) + { + if (hit.isTrigger) continue; + bool excluded = excludedColliders.Contains(hit); + Vector3 colliderLocal = _buildingRoot.InverseTransformPoint(hit.bounds.center); + bool insideFootprint = colliderLocal.x >= -0.5f && colliderLocal.x <= _roomSize.x + 0.5f && + colliderLocal.z >= -0.5f && colliderLocal.z <= _roomSize.z + 0.5f; + string path = GetTransformPath(hit.transform); + DebugLog.Info($" -> Collider: \"{path}\" (excluded={excluded}, insideFootprint={insideFootprint}, localPos=({colliderLocal.x:F2},{colliderLocal.y:F2},{colliderLocal.z:F2}), type={hit.GetType().Name})"); + } + } + + private static string GetTransformPath(Transform t) + { + string path = t.name; + Transform? parent = t.parent; + int depth = 0; + while (parent != null && depth < 5) + { + path = parent.name + "/" + path; + parent = parent.parent; + depth++; + } + return path; + } + + private static Material CreateFlatMaterial(Color color) + { + // Use a transparent unlit shader + Shader? shader = Shader.Find("Sprites/Default"); + if (shader == null) shader = Shader.Find("UI/Default"); + if (shader == null) shader = Shader.Find("Unlit/Color"); + + var mat = new Material(shader!); + mat.color = color; + return mat; + } + + #endregion + } +} diff --git a/Building/NavMeshRepairer.cs b/Building/NavMeshRepairer.cs deleted file mode 100644 index f2f337c..0000000 --- a/Building/NavMeshRepairer.cs +++ /dev/null @@ -1,649 +0,0 @@ -using System.Collections.Generic; -using S1MAPI.Utils; -using UnityEngine; -using UnityEngine.AI; - -namespace S1MAPI.Building -{ - /// - /// Records the position and dimensions of a doorway for NavMesh source filtering - /// and ramp/ground plane generation. Used for both exterior and interior doorways. - /// - public sealed class NavMeshDoorwayInfo - { - /// Center of the doorway in local building coordinates (Y=0, floor level). - public Vector3 Center { get; } - - /// Width of the doorway opening in meters. - public float Width { get; } - - /// Height of the doorway opening in meters. - public float Height { get; } - - /// Unit vector perpendicular to the wall face in the XZ plane. - public Vector3 InwardNormal { get; } - - /// Thickness of the wall containing this doorway. - public float WallThickness { get; } - - /// - /// Position at the base of the stairs in local building coordinates (ground level). - /// Null for interior doorways or exterior doorways without stairs. - /// - public Vector3? StairBasePosition { get; } - - /// - /// Create a NavMesh doorway info record. - /// - /// Door center in local building coordinates (Y=0) - /// Doorway width in meters - /// Doorway height in meters - /// Unit vector perpendicular to the wall face - /// Wall thickness in meters - /// Position at stair base (ground level), or null if no stairs - public NavMeshDoorwayInfo( - Vector3 center, float width, float height, - Vector3 inwardNormal, float wallThickness, - Vector3? stairBasePosition = null) - { - Center = center; - Width = width; - Height = height; - InwardNormal = inwardNormal; - WallThickness = wallThickness; - StairBasePosition = stairBasePosition; - } - } - - /// - /// Builds and manages runtime NavMesh for building interiors. - /// Collects building geometry (floor, walls, ramps) from the building hierarchy, - /// adds manual ground planes at stair bases, and builds a single connected NavMesh - /// from the ground patch through the ramp into the interior. - /// A carving obstacle removes the game's baked mesh in the covered area so NPCs - /// pathfind exclusively on the runtime surface within the building zone. - /// - /// - /// Call after the building is positioned in the scene. - /// Call when interior objects change (e.g. furniture moved). - /// Call when the building is destroyed. - /// - public sealed class NavMeshRepairer - { - #region Fields - - private readonly Transform _buildingRoot; - private readonly Vector3 _roomSize; - private readonly IReadOnlyList _doorways; - private readonly int _agentTypeID; - private readonly float _foundationHeight; - - private NavMeshDataInstance _navInstance; - private readonly List _rampObjects = new List(); - private GameObject? _obstacleGO; - private bool _isBuilt; - - #endregion - - #region Constructor - - /// - /// Create a new NavMesh repairer for a building. - /// - /// Root transform of the building (must be positioned before calling Build) - /// Interior room dimensions (width, height, depth) - /// Doorway positions for source filtering and ramp generation - /// NavMesh agent type to build for (0 = default agent) - /// Foundation height in meters (0 = no foundation). Used to carve ground NavMesh. - public NavMeshRepairer( - Transform buildingRoot, - Vector3 roomSize, - IReadOnlyList doorways, - int agentTypeID = 0, - float foundationHeight = 0f) - { - _buildingRoot = buildingRoot; - _roomSize = roomSize; - _doorways = doorways; - _agentTypeID = agentTypeID; - _foundationHeight = foundationHeight; - } - - #endregion - - #region Properties - - /// - /// Whether the NavMesh is currently active. - /// - public bool IsBuilt => - _isBuilt; - - #endregion - - #region Public API - - /// - /// Build a NavMesh covering the building interior, stair ramps, and ground patches - /// at each stair base. A carving obstacle removes the game's baked mesh in the - /// covered area. Must be called after the building is positioned in the scene. - /// - public void Build() - { - if (_isBuilt) - { - DebugLog.Warning("[NavMeshRepairer] NavMesh already built. Call Rebuild() to refresh."); - return; - } - - // 1. Create carving obstacle covering ONLY the building footprint. - // Does NOT extend to stair approach area — the baked mesh must persist - // there so it overlaps with our runtime ground planes, giving NPCs a - // cross-instance transition point. - // Created early to give Unity maximum time for async carving. - if (_foundationHeight > 0f) - { - CreateGroundObstacle(); - } - - Bounds contentBounds = ComputeLocalBounds(); - - // 2. Create invisible ramp colliders so the NavMesh has continuous walkable - // surface from floor level to ground level at each staircase. - CreateStairRamps(); - - // 3. Collect physics colliders from the building hierarchy as NavMesh sources. - // Sources are in local building coordinates. -#if MONO - var sources = new List(); - var markups = new List(); - foreach (Transform child in _buildingRoot) - { - if (child.name == Constants.Spatial.StairsFolderName || - child.name == Constants.Spatial.FoundationFolderName) - { - markups.Add(new NavMeshBuildMarkup { root = child, ignoreFromBuild = true }); - } - } - NavMeshBuilder.CollectSources( - _buildingRoot, ~0, NavMeshCollectGeometry.PhysicsColliders, - 0, markups, sources); -#elif IL2CPP - List sources = CollectBuildingSources(); -#endif - - // 4. Remove colliders that sit inside door openings (e.g. door panels/prefabs). - // Must run before adding manual sources so those aren't filtered out. - FilterDoorwaySources(sources); - - // 5. Add flat ground plane sources at each stair base so the runtime mesh - // has walkable surface at ground level connecting the ramp to the baked mesh. - AddGroundPlanes(sources); - - // 6. Add threshold sources at each doorway to bridge the wall-thickness gap - // between the ramp top (outer wall face) and the floor (inner wall face). - AddDoorwayThresholds(sources); - - // 7. Add a "Not Walkable" blocker at ground level covering the building interior. - // Area 1 (Not Walkable) takes absolute precedence during voxelization — - // prevents the runtime mesh from creating a walkable phantom surface at - // ground level inside the building. The floor at Y=0 is unaffected. - if (_foundationHeight > 0f) - { - sources.Add(new NavMeshBuildSource - { - shape = NavMeshBuildSourceShape.Box, - size = new Vector3(_roomSize.x, 0.5f, _roomSize.z), - transform = Matrix4x4.TRS( - new Vector3(_roomSize.x / 2f, -_foundationHeight, _roomSize.z / 2f), - Quaternion.identity, - Vector3.one), - area = 1 // Not Walkable - }); - } - - if (sources.Count == 0) - { - DebugLog.Warning("[NavMeshRepairer] No colliders found in building hierarchy."); - return; - } - - // 6. Compute bake bounds = content + expansion. - // The expansion zone extends past the obstacle, creating an overlap - // where both runtime and baked meshes coexist for smooth NPC transition. - Bounds bakeBounds = contentBounds; - bakeBounds.Expand(Constants.NavMesh.BoundsExpansion); - - // 7. Build NavMesh data — local sources positioned by building transform - NavMeshBuildSettings settings = NavMesh.GetSettingsByID(_agentTypeID); - - // Override voxel size to satisfy the 4-voxel rule for vertical separation. - // foundationHeight / voxelSize must be >= 4 to prevent surface merging. - // Use 6 voxels for safety margin; only override if needed. - if (_foundationHeight > 0f) - { - float maxVoxel = _foundationHeight / 6f; - if (settings.voxelSize > maxVoxel) - { - settings.overrideVoxelSize = true; - settings.voxelSize = maxVoxel; - DebugLog.Info($"[NavMeshRepairer] Overrode voxelSize to {maxVoxel:F3} " + - $"(foundation={_foundationHeight:F2}, voxels={_foundationHeight / maxVoxel:F1})"); - } - } - -#if IL2CPP - Il2CppSystem.Collections.Generic.List il2CppSources = sources.ToIl2CppList(); - NavMeshData navData = NavMeshBuilder.BuildNavMeshData( - settings, - il2CppSources, - bakeBounds, - _buildingRoot.position, - _buildingRoot.rotation); -#elif MONO - NavMeshData navData = NavMeshBuilder.BuildNavMeshData( - settings, - sources, - bakeBounds, - _buildingRoot.position, - _buildingRoot.rotation); -#endif - - if (navData == null) - { - DebugLog.Error("[NavMeshRepairer] NavMeshBuilder.BuildNavMeshData returned null."); - return; - } - - // 8. Register with NavMesh system and verify triangles were added -#if MONO - int trisBefore = NavMesh.CalculateTriangulation().indices.Length / 3; -#endif - _navInstance = NavMesh.AddNavMeshData(navData); -#if MONO - int trisAfter = NavMesh.CalculateTriangulation().indices.Length / 3; - int trisDelta = trisAfter - trisBefore; - if (trisDelta > 0) - DebugLog.Info($"[NavMeshRepairer] NavMesh added {trisDelta} triangles (total: {trisAfter})."); - else - DebugLog.Warning($"[NavMeshRepairer] NavMesh added ZERO triangles! " + - $"Runtime mesh is empty — interior pathing impossible. " + - $"(before={trisBefore}, after={trisAfter})"); -#endif - - // 9. Verify the runtime NavMesh is queryable at room center - Vector3 testWorld = _buildingRoot.TransformPoint( - new Vector3(_roomSize.x / 2f, Constants.NavMesh.VerifyTestYOffset, _roomSize.z / 2f)); - NavMeshHit hit; - bool found = NavMesh.SamplePosition(testWorld, out hit, Constants.NavMesh.VerifySampleRadius, NavMesh.AllAreas); - - if (!found) - { - DebugLog.Warning("[NavMeshRepairer] No NavMesh surface found at room center. " + - "Interior pathing will not work."); - } - else if (Mathf.Abs(hit.position.y - testWorld.y) >= Constants.NavMesh.VerifyMaxYDiff) - { - DebugLog.Warning($"[NavMeshRepairer] NavMesh surface at {hit.position} may be the game's " + - $"ground mesh, not our interior surface (expected Y≈{testWorld.y:F2})."); - } - - _isBuilt = true; - - DebugLog.Info($"[NavMeshRepairer] Built NavMesh: {sources.Count} sources, " + - $"bake bounds size={bakeBounds.size}"); - } - - /// - /// Tear down and rebuild the NavMesh. - /// Use when interior objects change (e.g. furniture placed or moved). - /// - public void Rebuild() - { - Remove(); - Build(); - } - - /// - /// Remove all NavMesh data and cleanup. - /// Call when the building is destroyed. - /// - public void Remove() - { - if (!_isBuilt) return; - - _navInstance.Remove(); - - foreach (GameObject ramp in _rampObjects) - { - UnityEngine.Object.Destroy(ramp); - } - _rampObjects.Clear(); - - if (_obstacleGO != null) - { - UnityEngine.Object.Destroy(_obstacleGO); - _obstacleGO = null; - } - - _isBuilt = false; - - DebugLog.Info("[NavMeshRepairer] Removed NavMesh data."); - } - - #endregion - - #region Private Methods — Bounds - - /// - /// Compute local-space bounds covering the building footprint, foundation depth, - /// and ground patches at each stair base. - /// - private Bounds ComputeLocalBounds() - { - // Start with building footprint - var bounds = new Bounds( - new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, _roomSize.z / 2f), - _roomSize); - - // Extend down to ground level - if (_foundationHeight > 0f) - { - bounds.Encapsulate(new Vector3(_roomSize.x / 2f, -_foundationHeight, _roomSize.z / 2f)); - } - - // Extend to cover ground patches past each stair base - float extension = Constants.NavMesh.GroundPatchExtension; - foreach (NavMeshDoorwayInfo doorway in _doorways) - { - if (!doorway.StairBasePosition.HasValue) continue; - - Vector3 stairBase = doorway.StairBasePosition.Value; - Vector3 outward = -doorway.InwardNormal; - Vector3 patchEnd = stairBase + outward * extension; - - bounds.Encapsulate(stairBase); - bounds.Encapsulate(patchEnd); - } - - return bounds; - } - - #endregion - - #region Private Methods — Ground Carving - - /// - /// Create a NavMeshObstacle that carves the game's baked NavMesh under the building - /// footprint ONLY. Does not extend to the stair approach area — the baked mesh must - /// persist there to overlap with the runtime ground planes, providing the cross-instance - /// transition point where NPCs step from the baked terrain onto the runtime surface. - /// Created early in to give Unity maximum time for async carving. - /// - private void CreateGroundObstacle() - { - _obstacleGO = new GameObject("NavMeshObstacle"); - _obstacleGO.transform.SetParent(_buildingRoot); - _obstacleGO.transform.localPosition = new Vector3( - _roomSize.x / 2f, -_foundationHeight, _roomSize.z / 2f); - - Vector3 obstacleSize = new Vector3( - _roomSize.x, - Constants.NavMesh.ObstacleHeight, - _roomSize.z); - - var obstacle = _obstacleGO.AddComponent(); - obstacle.shape = NavMeshObstacleShape.Box; - obstacle.center = Vector3.zero; - obstacle.size = obstacleSize; - obstacle.carving = true; - obstacle.carvingTimeToStationary = 0f; - - } - - #endregion - - #region Private Methods — Stair Ramps - - /// - /// Create invisible ramp colliders at each exterior doorway with stairs. - /// The ramp provides continuous walkable NavMesh from floor level down to - /// ground level, so multiple NPCs can walk the slope simultaneously. - /// - private void CreateStairRamps() - { - foreach (NavMeshDoorwayInfo doorway in _doorways) - { - if (!doorway.StairBasePosition.HasValue) continue; - - Vector3 top = doorway.Center; // (x, 0, z) at floor level - Vector3 bottom = doorway.StairBasePosition.Value; // (x, -fh, z) at ground level - - // Ramp geometry - Vector3 flatDelta = new Vector3(bottom.x - top.x, 0f, bottom.z - top.z); - float horizontalDist = flatDelta.magnitude; - float verticalDist = Mathf.Abs(bottom.y); // = foundationHeight - float rampLength = Mathf.Sqrt(horizontalDist * horizontalDist + verticalDist * verticalDist); - float slopeAngle = Mathf.Atan2(verticalDist, horizontalDist) * Mathf.Rad2Deg; - - Vector3 mid = (top + bottom) / 2f; - Vector3 outward = flatDelta.normalized; // flat direction from door to stair base - - // Create invisible ramp collider parented to building root. - // CollectSources will pick it up automatically. - GameObject rampGO = new GameObject("NavMeshRamp"); - rampGO.transform.SetParent(_buildingRoot); - rampGO.transform.localPosition = mid; - - // Face outward, then tilt down by slope angle around local X (right axis) - Quaternion facing = Quaternion.LookRotation(outward, Vector3.up); - rampGO.transform.localRotation = facing * Quaternion.AngleAxis(slopeAngle, Vector3.right); - - // Width must exceed the minimum ramp width plus agent-radius - // erosion on both edges, otherwise the walkable strip is so narrow - // that NPCs path to the corner instead of walking up the center. - float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinRampWidth) + Constants.NavMesh.RampErosionBuffer; - - BoxCollider col = rampGO.AddComponent(); - col.center = Vector3.zero; - col.size = new Vector3(rampWidth, Constants.NavMesh.RampColliderThickness, rampLength); - - _rampObjects.Add(rampGO); - } - } - - #endregion - - #region Private Methods — Ground Planes - - /// - /// Add flat NavMeshBuildSource boxes at ground level past each stair base. - /// These provide walkable surface at ground level that connects the ramp bottom - /// to the edge of the baked NavMesh, bridging the gap created by obstacle carving. - /// Sources are in local building coordinates. - /// - private void AddGroundPlanes(List sources) - { - float extension = Constants.NavMesh.GroundPatchExtension; - - foreach (NavMeshDoorwayInfo doorway in _doorways) - { - if (!doorway.StairBasePosition.HasValue) continue; - - Vector3 stairBase = doorway.StairBasePosition.Value; - Vector3 outward = -doorway.InwardNormal; - - // Ground plane centered between stair base and the far edge - Vector3 patchCenter = stairBase + outward * (extension / 2f); - - float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinRampWidth) - + Constants.NavMesh.RampErosionBuffer; - - // Align the plane with the outward direction - Quaternion patchRot = Quaternion.LookRotation(outward, Vector3.up); - - sources.Add(new NavMeshBuildSource - { - shape = NavMeshBuildSourceShape.Box, - size = new Vector3(rampWidth, Constants.NavMesh.RampColliderThickness, extension), - transform = Matrix4x4.TRS(patchCenter, patchRot, Vector3.one), - area = 0 - }); - } - } - - #endregion - - #region Private Methods — Doorway Source Filtering - - /// - /// Remove any collected source whose center falls inside a door opening volume. - /// Door prefabs (e.g. wooden doors, sliding doors) have colliders that - /// source collection picks up. These create low-overhead obstructions that - /// block NavMesh in the doorway. - /// Must be called after collecting sources but before adding manual sources - /// (ground planes, thresholds, blocker). - /// - private void FilterDoorwaySources(List sources) - { - for (int i = sources.Count - 1; i >= 0; i--) - { - NavMeshBuildSource s = sources[i]; - // Source positions from CollectSources are in WORLD space, - // but doorway coordinates are in LOCAL building space. - // Transform doorway geometry to world space for comparison. - Vector3 pos = new Vector3(s.transform.m03, s.transform.m13, s.transform.m23); - - foreach (NavMeshDoorwayInfo doorway in _doorways) - { - Vector3 worldCenter = _buildingRoot.TransformPoint(doorway.Center); - Vector3 worldNormal = _buildingRoot.TransformDirection(doorway.InwardNormal); - Vector3 worldTangent = new Vector3(-worldNormal.z, 0f, worldNormal.x); - - Vector3 delta = pos - worldCenter; - float normalDist = Mathf.Abs(Vector3.Dot(delta, worldNormal)); - float tangentDist = Mathf.Abs(Vector3.Dot(delta, worldTangent)); - float normalThreshold = doorway.WallThickness / 2f + Constants.NavMesh.DoorFilterNormalPadding; - - // Y check in world space: source must be between floor level and door top - float floorY = worldCenter.y; - if (normalDist <= normalThreshold && - tangentDist <= doorway.Width / 2f && - pos.y > floorY && pos.y < floorY + doorway.Height) - { - sources.RemoveAt(i); - break; - } - } - } - } - - #endregion - - #region Private Methods — Doorway Thresholds - - /// - /// Add flat walkable sources at each doorway to bridge the wall-thickness gap - /// between the ramp top (outer wall face) and the floor (inner wall face). - /// Without these, the NavMesh has a disconnected gap at every doorway. - /// Sources are in local building coordinates. - /// - private void AddDoorwayThresholds(List sources) - { - foreach (NavMeshDoorwayInfo doorway in _doorways) - { - if (!doorway.StairBasePosition.HasValue) continue; - - // Threshold centered in the wall at floor height (Y=0). - // Extends 0.2m past each wall face for robust overlap with ramp and floor. - Vector3 thresholdCenter = doorway.Center - + doorway.InwardNormal * (doorway.WallThickness / 2f); - - float thresholdDepth = doorway.WallThickness + 0.4f; - float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinRampWidth) - + Constants.NavMesh.RampErosionBuffer; - - Quaternion rot = Quaternion.LookRotation(doorway.InwardNormal, Vector3.up); - - sources.Add(new NavMeshBuildSource - { - shape = NavMeshBuildSourceShape.Box, - size = new Vector3(rampWidth, Constants.NavMesh.RampColliderThickness, thresholdDepth), - transform = Matrix4x4.TRS(thresholdCenter, rot, Vector3.one), - area = 0 - }); - } - } - - #endregion - - #region Private Methods — Source Collection - - /// - /// Scan the building hierarchy for BoxColliders and create NavMeshBuildSources. - /// Excludes colliders under the "Stairs" folder (steps block the ramp surface) - /// and the "Foundation" folder (bottom face creates a phantom walkable surface). - /// Sources are in local building coordinates so BuildNavMeshData can transform - /// them using the building's world position and rotation. - /// - private List CollectBuildingSources() - { - // Find stair transforms to exclude (same logic as the Mono markup path) - var excludedRoots = new HashSet(); - for (int i = 0; i < _buildingRoot.childCount; i++) - { - Transform child = _buildingRoot.GetChild(i); - if (child.name == Constants.Spatial.StairsFolderName || - child.name == Constants.Spatial.FoundationFolderName) - { - excludedRoots.Add(child); - } - } - - var sources = new List(); - BoxCollider[] colliders = _buildingRoot.GetComponentsInChildren(); - - foreach (BoxCollider collider in colliders) - { - // Skip colliders under excluded stair roots - if (IsChildOfAny(collider.transform, excludedRoots)) continue; - - // Compute world-space center, then convert to local building space - Vector3 worldCenter = collider.transform.TransformPoint(collider.center); - Vector3 localCenter = _buildingRoot.InverseTransformPoint(worldCenter); - - // Compute local rotation relative to building root - Quaternion localRot = Quaternion.Inverse(_buildingRoot.rotation) * collider.transform.rotation; - - // Actual box dimensions = collider size * transform scale - Vector3 worldSize = Vector3.Scale(collider.size, collider.transform.lossyScale); - - sources.Add(new NavMeshBuildSource - { - shape = NavMeshBuildSourceShape.Box, - size = worldSize, - transform = Matrix4x4.TRS(localCenter, localRot, Vector3.one), - area = 0 - }); - } - - return sources; - } - - /// - /// Check whether is a descendant of any transform in . - /// - private static bool IsChildOfAny(Transform t, HashSet roots) - { - Transform current = t.parent; - while (current != null) - { - if (roots.Contains(current)) return true; - current = current.parent; - } - return false; - } - - #endregion - } -} diff --git a/Building/NavigationBuilder.cs b/Building/NavigationBuilder.cs new file mode 100644 index 0000000..f834b90 --- /dev/null +++ b/Building/NavigationBuilder.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using S1MAPI.Utils; +using UnityEngine; +using UnityEngine.AI; +#if IL2CPP +using Il2CppInterop.Runtime.Injection; +#endif + +namespace S1MAPI.Building +{ + /// + /// Records the position and dimensions of a doorway for NavMesh source filtering + /// and ramp/ground plane generation. Used for both exterior and interior doorways. + /// + public sealed class NavDoorwayInfo + { + /// Center of the doorway in local building coordinates (Y=0, floor level). + public Vector3 Center { get; } + + /// Width of the doorway opening in meters. + public float Width { get; } + + /// Height of the doorway opening in meters. + public float Height { get; } + + /// Unit vector perpendicular to the wall face in the XZ plane. + public Vector3 InwardNormal { get; } + + /// Thickness of the wall containing this doorway. + public float WallThickness { get; } + + /// + /// Position at the base of the stairs in local building coordinates (ground level). + /// Null for interior doorways or exterior doorways without stairs. + /// + public Vector3? StairBasePosition { get; } + + /// + /// Whether this doorway is an interior wall doorway (between rooms) + /// rather than an exterior wall doorway. + /// + public bool IsInterior { get; } + + /// + /// Create a doorway info record. + /// + /// Door center in local building coordinates (Y=0) + /// Doorway width in meters + /// Doorway height in meters + /// Unit vector perpendicular to the wall face + /// Wall thickness in meters + /// Position at stair base (ground level), or null if no stairs + /// True for interior wall doorways between rooms + public NavDoorwayInfo( + Vector3 center, float width, float height, + Vector3 inwardNormal, float wallThickness, + Vector3? stairBasePosition = null, + bool isInterior = false) + { + Center = center; + Width = width; + Height = height; + InwardNormal = inwardNormal; + WallThickness = wallThickness; + StairBasePosition = stairBasePosition; + IsInterior = isInterior; + } + } + + /// + /// Manages NPC navigation for building interiors. + /// + /// Carves the building footprint from baked NavMesh using NavMeshObstacle, + /// then provides custom A* pathfinding for interior navigation. + /// A Harmony patch on NPCMovement.SetDestination automatically intercepts NPCs + /// whose destinations fall inside the building — no manual SendNPCTo calls needed. + /// NPCs seamlessly transition between exterior NavMesh and interior A* pathfinding. + /// + /// + /// + /// Call after the building is positioned in the scene. + /// Call when interior objects change (e.g. furniture moved). + /// Call when the building is destroyed. + /// + public sealed class NavigationBuilder + { + #region Fields + + private readonly Transform _buildingRoot; + private readonly Vector3 _roomSize; + private readonly IReadOnlyList _doorways; + private readonly float _foundationHeight; + private readonly float _wallThickness; + + // Physical objects + private readonly List _rampObjects = new List(); + + // Carving obstacles placed at wall positions to block terrain NavMesh + private readonly List _carvingObstacles = new List(); + + // Custom interior pathfinding + private InteriorNavigatorCore? _navCore; + private InteriorNavigator? _navShell; + + private bool _isBuilt; + + #endregion + + #region Constructor + + /// + /// Create a new navigation builder for a building. + /// + /// Root transform of the building (must be positioned before calling Build) + /// Interior room dimensions (width, height, depth) + /// Doorway positions for source filtering and ramp generation + /// Exterior wall thickness in meters. + /// Foundation height in meters (0 = no foundation). + public NavigationBuilder( + Transform buildingRoot, + Vector3 roomSize, + IReadOnlyList doorways, + float wallThickness, + float foundationHeight = 0f) + { + _buildingRoot = buildingRoot; + _roomSize = roomSize; + _doorways = doorways; + _wallThickness = wallThickness; + _foundationHeight = foundationHeight; + } + + #endregion + + #region Properties + + /// + /// Whether the NavMesh is currently active. + /// + public bool IsBuilt => + _isBuilt; + + #endregion + + #region Public API + + /// + /// Set up interior NPC navigation for this building. + /// + /// Carves baked NavMesh under the footprint and installs a custom A* + /// pathfinding grid. NPCs whose SetDestination targets fall inside the + /// building are automatically intercepted and navigated through the interior. + /// + /// Must be called after the building is positioned in the scene. + /// + public void Build() + { + if (_isBuilt) + { + DebugLog.Warning("[NavigationBuilder] NavMesh already built. Call Rebuild() to refresh."); + return; + } + + CreateStairRamps(); + + // Carve the full building footprint — removes all baked NavMesh inside. + PlaceFootprintCarvingObstacle(); + + // Build A* pathfinding grid and navigation core. + var pathGrid = new InteriorPathGrid( + _buildingRoot, _roomSize, _doorways); + _navCore = new InteriorNavigatorCore( + pathGrid, _doorways, _buildingRoot, _roomSize, _foundationHeight); + + // Attach thin MonoBehaviour shell for Unity lifecycle forwarding. +#if IL2CPP + if (!ClassInjector.IsTypeRegisteredInIl2Cpp()) + ClassInjector.RegisterTypeInIl2Cpp(); +#endif + _navShell = _buildingRoot.gameObject.AddComponent(); + _navShell._core = _navCore; + + _isBuilt = true; + } + + /// + /// Tear down and rebuild the NavMesh. + /// Use when interior objects change (e.g. furniture placed or moved). + /// + public void Rebuild() + { + Remove(); + Build(); + } + + /// + /// Check if an NPC is currently being managed by this building's interior navigator. + /// + public bool IsNPCInside(Component npc) + { + return _navCore != null && _navCore.IsTracking(npc); + } + + /// + /// Show or hide the interior pathfinding grid visualization. + /// Green cells are walkable, red cells are blocked. + /// + public void VisualizePathGrid(bool show = true) + { + _navCore?.VisualizeGrid(show); + } + + /// + /// Diagnose why a specific grid cell is blocked. Logs the blocking reason + /// (wall margin, interior wall, physics collider name) to the console. + /// Cell coordinates are visible in the visualization quad names (Cell_X_Z). + /// + public void DiagnoseCell(int gridX, int gridZ) + { + _navCore?.DiagnoseCell(gridX, gridZ); + } + + /// + /// Remove all NavMesh data and cleanup. + /// Call when the building is destroyed. + /// + public void Remove() + { + if (!_isBuilt) return; + + // Release all NPCs and clean up navigation core + if (_navCore != null) + { + _navCore.ReleaseAllNPCs(); + _navCore.Cleanup(); + _navCore = null; + } + if (_navShell != null) + { + _navShell._core = null; // prevent double cleanup from OnDestroy + UnityEngine.Object.Destroy(_navShell); + _navShell = null; + } + + foreach (GameObject obs in _carvingObstacles) + UnityEngine.Object.Destroy(obs); + _carvingObstacles.Clear(); + + foreach (GameObject ramp in _rampObjects) + UnityEngine.Object.Destroy(ramp); + _rampObjects.Clear(); + + _isBuilt = false; + DebugLog.Info("[NavigationBuilder] Removed NavMesh data."); + } + + #endregion + + #region Private — NavMesh Carving + + /// + /// Place a single carving obstacle covering the entire building footprint. + /// This carves out ALL baked NavMesh inside the building (terrain island). + /// Test: if carving only affects baked data (not runtime-added instances), + /// the separately-added interior NavMeshData survives untouched. + /// + private void PlaceFootprintCarvingObstacle() + { + // Obstacle covers full footprint, tall enough to intersect terrain NavMesh + // at any height. Positioned at building center. + float height = _roomSize.y + 4f; // generous vertical coverage + + Vector3 localCenter = new Vector3( + _roomSize.x / 2f, + height / 2f, + _roomSize.z / 2f); + + var go = new GameObject("NavMeshFootprintCarve"); + go.transform.SetParent(_buildingRoot); + go.transform.localPosition = localCenter; + go.transform.localRotation = Quaternion.identity; + + NavMeshObstacle obstacle = go.AddComponent(); + obstacle.shape = NavMeshObstacleShape.Box; + obstacle.carving = true; + obstacle.carveOnlyStationary = true; + obstacle.size = new Vector3(_roomSize.x, height, _roomSize.z); + obstacle.center = Vector3.zero; + + _carvingObstacles.Add(go); + + DebugLog.Info($"[NavigationBuilder] Footprint carve: size=({_roomSize.x:F1}, {height:F1}, {_roomSize.z:F1}), " + + $"worldCenter={go.transform.position}"); + } + + #endregion + + #region Private — Stair Ramps + + /// + /// Create invisible ramp colliders at each exterior doorway with stairs. + /// + private void CreateStairRamps() + { + foreach (NavDoorwayInfo doorway in _doorways) + { + if (!doorway.StairBasePosition.HasValue) continue; + + Vector3 top = doorway.Center; + Vector3 bottom = doorway.StairBasePosition.Value; + + Vector3 flatDelta = new Vector3(bottom.x - top.x, 0f, bottom.z - top.z); + float horizontalDist = flatDelta.magnitude; + float verticalDist = Mathf.Abs(bottom.y); + float rampLength = Mathf.Sqrt(horizontalDist * horizontalDist + verticalDist * verticalDist); + float slopeAngle = Mathf.Atan2(verticalDist, horizontalDist) * Mathf.Rad2Deg; + + Vector3 mid = (top + bottom) / 2f; + Vector3 outward = flatDelta.normalized; + + GameObject rampGO = new GameObject("NavMeshRamp"); + rampGO.transform.SetParent(_buildingRoot); + rampGO.transform.localPosition = mid; + + Quaternion facing = Quaternion.LookRotation(outward, Vector3.up); + rampGO.transform.localRotation = facing * Quaternion.AngleAxis(slopeAngle, Vector3.right); + + float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinRampWidth) + + Constants.NavMesh.RampErosionBuffer; + + BoxCollider col = rampGO.AddComponent(); + col.center = Vector3.zero; + col.size = new Vector3(rampWidth, Constants.NavMesh.RampColliderThickness, rampLength); + + _rampObjects.Add(rampGO); + + Vector3 worldTop = _buildingRoot.TransformPoint(top); + Vector3 worldBottom = _buildingRoot.TransformPoint(bottom); + DebugLog.Info($"[NavigationBuilder] Ramp: worldPos={rampGO.transform.position}, " + + $"top={worldTop}, bottom={worldBottom}, " + + $"width={rampWidth:F2}, length={rampLength:F2}, angle={slopeAngle:F1}°"); + } + } + + #endregion + } +} diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 49bec69..5dde003 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -1,8 +1,9 @@ # Coding Standards S1MAPI is a mesh and building construction library for Schedule 1 mods. -The core principle: **avoid ScheduleOne types** to remain resilient across game updates. -S1MAPI uses Unity primitives and FishNet only—no `Assembly-CSharp` references. +The core principle: **no compile-time dependencies on ScheduleOne types** to remain resilient across game updates. +S1MAPI uses Unity primitives and FishNet only—no `Assembly-CSharp` imports. Where game-type +interaction is unavoidable, use reflection with graceful fallbacks. ## General Best Practice * Review the codebase thoroughly before submitting a PR. @@ -170,8 +171,10 @@ public ProceduralMeshBuilder AddBox(...) { ... } * Group related members together using regions. ## What **NOT** to Do -* **Do not** reference ScheduleOne types (`Assembly-CSharp.dll`). - This is the core rule—S1MAPI must remain update-resilient. +* **Do not** add compile-time references to ScheduleOne types (`Assembly-CSharp.dll`). + S1MAPI must remain update-resilient—no `using ScheduleOne.*` imports or direct type usage. + When interaction with game types is unavoidable (e.g. NPC navigation), use **reflection** + with graceful fallbacks so the code degrades safely if the game changes. * **Do not** use magic strings—prefer enums or constants. * **Do not** ignore compiler warnings. * **Do not** leave commented-out code in commits. diff --git a/S1MAPI.csproj b/S1MAPI.csproj index 71ad6b8..40fcb36 100644 --- a/S1MAPI.csproj +++ b/S1MAPI.csproj @@ -27,6 +27,7 @@ netstandard2.1 + S1MAPI_Mono MONO true @@ -34,6 +35,7 @@ net6.0 + S1MAPI_Il2cpp IL2CPP false @@ -106,6 +108,11 @@ $(MonoAssembliesPath)\FishNet.Runtime.dll False + + + $(MonoMLPath)\0Harmony.dll + False + @@ -185,6 +192,11 @@ $(Il2CppAssembliesPath)\Il2CppFishNet.Runtime.dll False + + + $(Il2CppMLAssembliesPath)\0Harmony.dll + False + diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 6f59232..93bd1ac 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -147,7 +147,7 @@ public static class Spatial /// /// GameObject folder name for stair geometry under the building root. - /// Used by DecorBuilder to parent step colliders and by NavMeshRepairer + /// Used by DecorBuilder to parent step colliders and by NavigationBuilder /// to exclude them from NavMesh source collection. /// public const string StairsFolderName = "Stairs"; @@ -302,7 +302,7 @@ public static class Roof } /// - /// NavMesh repair constants. + /// NavigationBuilder constants. /// public static class NavMesh { @@ -323,45 +323,38 @@ public static class NavMesh /// Thin enough to not interfere with gameplay, thick enough for NavMesh voxelization. /// public const float RampColliderThickness = 0.1f; + } - /// - /// Padding added to NavMesh build bounds to avoid clipping walkable surfaces at edges. - /// - public const float BoundsExpansion = 1.0f; + /// + /// Interior A* pathfinding constants. + /// + public static class InteriorNav + { + /// Maximum target cell size for the interior pathfinding grid. + /// Actual cell size is computed by + /// to evenly divide the room dimensions. + public const float MaxGridCellSize = Spatial.DefaultGridSize; - /// - /// Radius for NavMesh.SamplePosition verification queries in meters. - /// - public const float VerifySampleRadius = 0.5f; + /// Wall margin — cells within this distance of walls are unwalkable. + /// Must be less than CellSize/2 (0.25m) so NPCs can walk on 1-cell-wide paths. + public const float WallMargin = 0.15f; - /// - /// Maximum acceptable Y-distance between expected and actual NavMesh surface - /// during verification, in meters. - /// - public const float VerifyMaxYDiff = 0.5f; + /// Re-pathfind interval for chase mode in seconds. + public const float ChaseRepathInterval = 0.2f; - /// - /// Y-offset above floor level for the NavMesh verification test point. - /// - public const float VerifyTestYOffset = 0.1f; + /// Distance threshold for considering NPC arrived at an intermediate waypoint. + public const float WaypointArrivalThreshold = 0.3f; - /// - /// Height of the ground-carving NavMeshObstacle in meters. - /// - public const float ObstacleHeight = 0.5f; + /// Distance threshold for considering NPC arrived at final destination. + public const float DestinationArrivalThreshold = 0.5f; - /// - /// Distance past the stair base that the ground plane extends, in meters. - /// Provides walkable surface at ground level between the ramp bottom and the - /// baked NavMesh, bridging the gap created by obstacle carving. - /// - public const float GroundPatchExtension = 3.0f; + /// Distance for detecting NPC arrival at doorway exterior point. + /// Generous threshold ensures the NPC triggers entry even if the NavMesh agent + /// stops short of the exact exterior point due to carving boundary erosion. + public const float DoorwayApproachThreshold = 4.0f; - /// - /// Extra padding beyond half wall thickness when filtering door opening sources. - /// Accounts for colliders slightly offset from the wall center plane. - /// - public const float DoorFilterNormalPadding = 0.15f; + /// Rotation speed in degrees per second for NPC facing direction. + public const float RotationSpeed = 360f; } /// @@ -412,6 +405,13 @@ public static class Terrain /// Default padding around terrain flattening bounds in meters. /// public const float DefaultFlattenPadding = 0.5f; + + /// + /// Default blend distance for terrain edge smoothing in meters. + /// Terrain smoothly transitions from the flattened height back to + /// natural terrain over this distance. + /// + public const float DefaultBlendDistance = 3f; } /// From 6f2b83b5ad674091609571037c9f98650fa6e68e Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 23 Mar 2026 09:56:45 -0400 Subject: [PATCH 33/64] fix(Building): clear FoliageRustleSound objects during terrain clearing --- Building/Structural/TerrainClearer.cs | 11 +++++------ Utils/Constants.cs | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Building/Structural/TerrainClearer.cs b/Building/Structural/TerrainClearer.cs index a07aed5..1fb8bb1 100644 --- a/Building/Structural/TerrainClearer.cs +++ b/Building/Structural/TerrainClearer.cs @@ -283,7 +283,8 @@ private static int ClearSceneObjects( GameObject target = ResolveLodRoot(t.gameObject); // Skip living entities (players, NPCs) - if (BuildingUtilities.IsLivingEntity(t, livingRoots, staticRoots)) continue; + if (BuildingUtilities.IsLivingEntity(t, livingRoots, staticRoots)) + continue; // Pass 1: everything inside the building footprint, except protected objects. if (inFootprint && options.ClearSceneObjects) @@ -307,10 +308,9 @@ private static int ClearSceneObjects( } // Catch-all: scan for renderer-less objects matching vegetation keywords - // (e.g. invisible tree rustle audio, vegetation scripts without meshes). - // SAFETY: only destroy childless objects whose name matches a vegetation - // keyword. AudioSource alone is NOT sufficient — the game attaches audio - // components to infrastructure objects that must not be destroyed. + // (e.g. FoliageRustleSound trigger colliders, vegetation scripts without meshes). + // Objects with children are included if they match a vegetation keyword — + // game components like FoliageRustleSound have child Container GameObjects. if (options.VegetationKeywords != null && options.VegetationKeywords.Length > 0) { Transform[] allTransforms = UnityEngine.Object.FindObjectsOfType(); @@ -320,7 +320,6 @@ private static int ClearSceneObjects( if (t.GetComponent() != null) continue; if (t.GetComponent() != null) continue; // Already handled above. if (preserved.Contains(t.GetInstanceID())) continue; - if (t.childCount > 0) continue; bool inFootprint = footprintBounds.Contains(t.position); bool inVegetationZone = !inFootprint && vegetationBounds.Contains(t.position); diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 93bd1ac..64faeb0 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -389,7 +389,7 @@ public static class Terrain /// public static readonly string[] DefaultVegetationKeywords = { - "Rock", "Boulder", "Shrub", "Bush", "Tree rustle" + "Rock", "Boulder", "Shrub", "Bush", "Tree rustle", "Foliage" }; /// From 96f54fac9943f465a263c1fbd0c4f8562bb2c886 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 13:16:13 -0400 Subject: [PATCH 34/64] fix(Building): improve NPC chase repath and stuck detection in interior navigation --- Building/InteriorNavigatorCore.cs | 112 ++++++++++++++++++++++++------ Building/InteriorPathGrid.cs | 7 +- 2 files changed, 93 insertions(+), 26 deletions(-) diff --git a/Building/InteriorNavigatorCore.cs b/Building/InteriorNavigatorCore.cs index def16e2..a3d758d 100644 --- a/Building/InteriorNavigatorCore.cs +++ b/Building/InteriorNavigatorCore.cs @@ -53,8 +53,11 @@ private sealed class TrackedNPC public Vector3 LerpStart; public Vector3 LerpEnd; public Vector3? PendingExteriorDestination; // set when NPC exits to resume navigation + public Vector3? SavedChasePosition; // chase target position saved before exit public float RepathTimer; // fallback re-pathfind timer for all NPCs public float StuckTimer; // time NPC hasn't moved + public Vector3 StuckStartPos; // position when stuck timer began + public Vector3 LastChaseTargetLocal; // last chase target used for repath (avoids redundant recompute) public float ApproachStartTime; // Time.time when Approaching state began // Cached reflection results @@ -105,7 +108,6 @@ public void SetValue(object target, object? value) private readonly Dictionary _tracked = new Dictionary(); private readonly List _removeQueue = new List(); - private readonly Dictionary _lastPositions = new Dictionary(); private float _doorwayScanTimer; private float _approachLogTimer; private static readonly Collider[] _scanBuffer = new Collider[32]; @@ -297,7 +299,10 @@ private static bool SetDestinationPrefix(object __instance, Vector3 pos) existing.TargetLocal = localPos; existing.OnArrival = null; if (chaseTarget != null) + { existing.ChaseTarget = chaseTarget; + existing.LastChaseTargetLocal = localPos; + } if (existing.State == NPCNavState.Inside) nav.ComputePathToTarget(existing); @@ -647,7 +652,6 @@ public void Update() { _globallyManaged.Remove(npc); _tracked.Remove(npc); - _lastPositions.Remove(npc); } } @@ -935,8 +939,19 @@ private void UpdateInside(Component npc, TrackedNPC data) if (data.ChaseRepathTimer <= 0f) { data.ChaseRepathTimer = Constants.InteriorNav.ChaseRepathInterval; - data.TargetLocal = targetLocal; - ComputePathToTarget(data); + + // Only recompute if the target has moved more than one grid cell. + // This avoids resetting PathIndex every 0.2s when the target barely moved, + // which was causing NPCs to repeatedly re-traverse waypoint 0. + float targetMovedSq = (targetLocal.x - data.LastChaseTargetLocal.x) * (targetLocal.x - data.LastChaseTargetLocal.x) + + (targetLocal.z - data.LastChaseTargetLocal.z) * (targetLocal.z - data.LastChaseTargetLocal.z); + float cellThreshold = _grid.CellSize * 2f; + if (targetMovedSq > cellThreshold * cellThreshold || data.Path == null) + { + data.TargetLocal = targetLocal; + data.LastChaseTargetLocal = targetLocal; + ComputePathToTarget(data); + } } } @@ -955,26 +970,34 @@ private void UpdateInside(Component npc, TrackedNPC data) } } - // Stuck detection — if NPC hasn't moved for 2s, recompute path + // Stuck detection — if NPC hasn't moved meaningfully over 2 seconds, recompute path. + // Uses cumulative displacement (not per-frame delta) to avoid false positives + // at high framerates where per-frame movement is tiny but NPC IS progressing. if (data.Path != null && data.PathIndex < data.Path.Count) { - float movedSq = (pos - _lastPositions.GetValueOrDefault(npc, pos)).sqrMagnitude; - if (movedSq < 0.01f) // less than 0.1m moved + if (data.StuckTimer == 0f) + data.StuckStartPos = pos; + + data.StuckTimer += Time.deltaTime; + + float displacementSq = (pos.x - data.StuckStartPos.x) * (pos.x - data.StuckStartPos.x) + + (pos.z - data.StuckStartPos.z) * (pos.z - data.StuckStartPos.z); + + if (displacementSq > 0.25f) // moved > 0.5m from start → making progress { - data.StuckTimer += Time.deltaTime; - if (data.StuckTimer > 1.5f) - { - data.StuckTimer = 0f; - DebugLog.Warning($"[InteriorNavigator] NPC stuck, recomputing path. speed={data.Speed:F1}"); - ComputePathToTarget(data); - } + data.StuckTimer = 0f; } - else + else if (data.StuckTimer > 2.0f) { data.StuckTimer = 0f; + Vector3 waypoint = data.Path[data.PathIndex]; + DebugLog.Warning($"[InteriorNavigator] NPC stuck (displaced {Mathf.Sqrt(displacementSq):F2}m in 2s). " + + $"speed={data.Speed:F1}, pathIdx={data.PathIndex}/{data.Path.Count}, " + + $"npcWorld=({pos.x:F1},{pos.y:F1},{pos.z:F1}), " + + $"waypoint=({waypoint.x:F1},{waypoint.y:F1},{waypoint.z:F1})"); + ComputePathToTarget(data); } } - _lastPositions[npc] = pos; UpdatePathFollow(npc, data, onComplete: () => { @@ -1000,6 +1023,14 @@ private void BeginExit(Component npc, TrackedNPC data) data.TargetDoorway = door; ComputeDoorwayPoints(data, door); + // Save chase target position before clearing — used by ReleaseNPC to + // resume pursuit on exterior NavMesh after the NPC exits the building. + if (data.ChaseTarget != null) + { + try { data.SavedChasePosition = data.ChaseTarget.position; } + catch { /* destroyed */ } + } + // Pathfind to doorway interior point data.Path = _grid.FindPath(currentLocal, _buildingRoot.InverseTransformPoint(data.DoorwayInteriorWorld)); data.PathIndex = 0; @@ -1021,6 +1052,11 @@ private void UpdatePathFollow(Component npc, TrackedNPC data, Action onComplete) // Refresh speed each frame (catches walk→run transitions in chase mode) data.Speed = GetNPCSpeed(data); + if (data.Speed < 0.1f) + { + DebugLog.Warning($"[InteriorNavigator] NPC speed near zero ({data.Speed:F3}), forcing minimum."); + data.Speed = 1.8f; // fallback to walk speed + } Vector3 target = data.Path[data.PathIndex]; Vector3 pos = npc.transform.position; @@ -1073,6 +1109,20 @@ private void ComputePathToTarget(TrackedNPC data) { DebugLog.Warning("[InteriorNavigator] No path found to target " + $"({data.TargetLocal.x:F1}, {data.TargetLocal.z:F1})"); + return; + } + + // Skip the first waypoint if it's at the NPC's current position. + // FindPath always starts from the NPC's current grid cell, so waypoint 0 + // is nearly always right where we stand — advancing past it avoids wasting + // a frame on a zero-distance move after every repath. + if (data.Path.Count > 1) + { + Vector3 wp0 = data.Path[0]; + Vector3 pos = data.NpcComponent.transform.position; + float distSq = (pos.x - wp0.x) * (pos.x - wp0.x) + (pos.z - wp0.z) * (pos.z - wp0.z); + if (distSq < _grid.CellSize * _grid.CellSize) + data.PathIndex = 1; } } @@ -1275,14 +1325,26 @@ private void ReleaseNPC(Component npc, TrackedNPC data, bool warpToExterior) EnableAgent(data); + // Restore HasDestination — we cleared it in BeginDoorwayEntry to prevent + // UpdateDestination from overwriting our agent destination. If not restored, + // the game's pursuit AI stops generating SetDestination calls and the NPC + // stands idle forever (won't re-enter building if player goes back inside). + if (data.MovementRef != null && _hasDestinationAccessor.IsValid) + { + try { _hasDestinationAccessor.SetValue(data.MovementRef, true); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] RestoreHasDestination failed: {ex.Message}"); } + } + // Must remove from global set BEFORE invoking SetDestination // so the Harmony prefix lets the call through to the original method. _globallyManaged.Remove(npc); _removeQueue.Add(npc); - // Resume navigation to pending exterior destination if one was set - // (e.g. game called SetDestination(outside) while NPC was inside) - if (data.PendingExteriorDestination.HasValue && + // Resume navigation: prefer pending exterior destination (game called + // SetDestination(outside) while NPC was inside), then saved chase position + // (NPC exited because chase target left building). + Vector3? resumeDestination = data.PendingExteriorDestination ?? data.SavedChasePosition; + if (resumeDestination.HasValue && data.MovementRef != null && _originalSetDestination != null) { @@ -1291,15 +1353,19 @@ private void ReleaseNPC(Component npc, TrackedNPC data, bool warpToExterior) // Parameters: (Vector3 destination, Action callback, float walkSpeedMult, float runSpeedMult) _originalSetDestination.Invoke( data.MovementRef, - new object?[] { data.PendingExteriorDestination.Value, null, 1f, 1f }); + new object?[] { resumeDestination.Value, null, 1f, 1f }); + DebugLog.Info($"[InteriorNavigator] NPC released, resuming navigation to " + + $"({resumeDestination.Value.x:F1}, {resumeDestination.Value.z:F1})"); } catch (Exception ex) { - DebugLog.Warning($"[InteriorNavigator] Failed to set pending destination: {ex.Message}"); + DebugLog.Warning($"[InteriorNavigator] Failed to set resume destination: {ex.Message}"); } } - - DebugLog.Info("[InteriorNavigator] NPC released from building."); + else + { + DebugLog.Info("[InteriorNavigator] NPC released from building (no resume destination)."); + } } private void DisableAgent(TrackedNPC data) diff --git a/Building/InteriorPathGrid.cs b/Building/InteriorPathGrid.cs index 7909dfa..532fa8e 100644 --- a/Building/InteriorPathGrid.cs +++ b/Building/InteriorPathGrid.cs @@ -236,10 +236,11 @@ private void MarkDoorwayOpenings() alongDist = Mathf.Abs(local.z - center.z); } - // Half-cell buffer on width ensures cells whose center is - // at the doorway edge get opened (strict < would miss them). + // Full-cell buffer on width ensures NPCs can path through + // without clipping the door frame edges. NPC capsule radius + // (~0.35m) needs clearance beyond the nominal doorway width. if (perpDist < door.WallThickness / 2f + _cellSize && - alongDist < halfWidth + _cellSize * 0.5f) + alongDist < halfWidth + _cellSize) { _walkable[gz * _gridWidth + gx] = true; } From 957804bd58753983d93af763fcf6b447145520ad Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 13:20:12 -0400 Subject: [PATCH 35/64] fix(Building): add terrain retry queue for IL2CPP late-load race condition --- Building/Structural/TerrainClearer.cs | 30 ++- Building/Structural/TerrainFlattener.cs | 35 +++- Building/Structural/TerrainRetryBehaviour.cs | 186 +++++++++++++++++++ 3 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 Building/Structural/TerrainRetryBehaviour.cs diff --git a/Building/Structural/TerrainClearer.cs b/Building/Structural/TerrainClearer.cs index 1fb8bb1..f066801 100644 --- a/Building/Structural/TerrainClearer.cs +++ b/Building/Structural/TerrainClearer.cs @@ -107,11 +107,38 @@ internal static void ClearArea(Bounds bounds, ClearingOptions? options = null) /// The building root GameObject (must be positioned). /// The building's room dimensions. /// Clearing configuration. Uses defaults if null. - public static void ClearAroundBuilding( + /// True if terrain was available and clearing executed immediately; false if terrain wasn't + /// available (a background retry has been queued automatically). + public static bool ClearAroundBuilding( GameObject buildingRoot, Vector3 roomSize, ClearingOptions? options = null) + { + try + { + if (ClearAroundBuildingCore(buildingRoot, roomSize, options)) + return true; + } + catch (System.Exception ex) + { + // IL2CPP clients can throw TypeInitializationException when terrain + // runtime generics aren't initialized yet. Catch and fall through to retry. + DebugLog.Warning($"[TerrainClearer] ClearAroundBuilding threw (will retry): {ex.GetType().Name}: {ex.Message}"); + } + + // Terrain not loaded or threw — queue automatic retry on the building root + DebugLog.Info("[TerrainClearer] Terrain not available — queuing retry."); + TerrainRetryQueue.Enqueue(buildingRoot, + () => ClearAroundBuildingCore(buildingRoot, roomSize, options)); + return false; + } + + private static bool ClearAroundBuildingCore( + GameObject buildingRoot, Vector3 roomSize, ClearingOptions? options) { ClearingOptions opts = options ?? ClearingOptions.Default; + if (opts.ClearTerrainTrees && Terrain.activeTerrains.Length == 0) + return false; + // Auto-populate Preserved with the building hierarchy if not set, // without mutating the caller's options instance. if (opts.Preserved == null) @@ -131,6 +158,7 @@ public static void ClearAroundBuilding( Bounds bounds = ComputeWorldBounds(buildingRoot.transform, roomSize); ClearArea(bounds, opts); + return true; } #endregion diff --git a/Building/Structural/TerrainFlattener.cs b/Building/Structural/TerrainFlattener.cs index f91f1ee..baca14c 100644 --- a/Building/Structural/TerrainFlattener.cs +++ b/Building/Structural/TerrainFlattener.cs @@ -121,10 +121,35 @@ private static bool ResolveDetailICalls() /// Clear grass and detail layers in the flattened region. /// Distance in meters over which terrain smoothly transitions /// from the flattened height back to natural terrain. 0 = hard edge. - public static void FlattenUnder( + /// True if terrain was found and flattened immediately; false if terrain wasn't available + /// (a background retry has been queued automatically). + public static bool FlattenUnder( GameObject buildingRoot, Vector3 roomSize, float targetWorldY, float padding = Constants.Terrain.DefaultFlattenPadding, bool clearDetails = true, float blendDistance = 0f) + { + try + { + if (FlattenUnderCore(buildingRoot, roomSize, targetWorldY, padding, clearDetails, blendDistance)) + return true; + } + catch (System.Exception ex) + { + // IL2CPP clients can throw TypeInitializationException when terrain + // runtime generics aren't initialized yet. Catch and fall through to retry. + DebugLog.Warning($"[TerrainFlattener] FlattenUnder threw (will retry): {ex.GetType().Name}: {ex.Message}"); + } + + // Terrain not loaded or threw — queue automatic retry on the building root + DebugLog.Info("[TerrainFlattener] Terrain not available — queuing retry."); + TerrainRetryQueue.Enqueue(buildingRoot, + () => FlattenUnderCore(buildingRoot, roomSize, targetWorldY, padding, clearDetails, blendDistance)); + return false; + } + + private static bool FlattenUnderCore( + GameObject buildingRoot, Vector3 roomSize, + float targetWorldY, float padding, bool clearDetails, float blendDistance) { Bounds innerBounds = ComputeXZBounds(buildingRoot.transform, roomSize, padding); @@ -135,10 +160,7 @@ public static void FlattenUnder( Terrain? terrain = FindCoveringTerrain(innerBounds); if (terrain == null) - { - DebugLog.Warning("[TerrainFlattener] No terrain found covering building footprint."); - return; - } + return false; TerrainData tData = terrain.terrainData; Vector3 terrainPos = terrain.transform.position; @@ -157,7 +179,7 @@ public static void FlattenUnder( if (sampleWidth <= 0 || sampleHeight <= 0) { DebugLog.Warning("[TerrainFlattener] Computed zero-size sample region."); - return; + return false; } // Compute blend zone size in samples for each axis @@ -188,6 +210,7 @@ public static void FlattenUnder( DebugLog.Info($"[TerrainFlattener] Flattened {sampleWidth}x{sampleHeight} samples " + $"to Y={targetWorldY:F2} on terrain '{terrain.name}'."); + return true; } #endregion diff --git a/Building/Structural/TerrainRetryBehaviour.cs b/Building/Structural/TerrainRetryBehaviour.cs new file mode 100644 index 0000000..52160a6 --- /dev/null +++ b/Building/Structural/TerrainRetryBehaviour.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using S1MAPI.Utils; +using UnityEngine; + +#if IL2CPP +using Il2CppInterop.Runtime.Injection; +#endif + +namespace S1MAPI.Building.Structural +{ + /// + /// Static operations queue for deferred terrain operations. + /// Separated from so that generic delegates + /// never appear on the ClassInjector-registered MonoBehaviour — IL2CPP cannot + /// marshal generic delegate parameters on injected types. + /// + internal static class TerrainRetryQueue + { + private const float RetryInterval = 1f; + private const int MaxRetries = 30; + + private sealed class RetryState + { + public readonly List> Ops = new List>(); + public int Attempts; + public float NextRetryTime; + } + + private static readonly Dictionary States = new Dictionary(); + + /// + /// Ensure a exists on the target + /// and queue an operation to retry when terrain becomes available. + /// + internal static void Enqueue(GameObject target, Func operation) + { + var behaviour = TerrainRetryBehaviour.EnsureOn(target); + int id = behaviour.GetInstanceID(); + + if (!States.TryGetValue(id, out var state)) + { + state = new RetryState { NextRetryTime = Time.unscaledTime + RetryInterval }; + States[id] = state; + } + state.Ops.Add(operation); + + DebugLog.Info($"[TerrainRetry] Queued operation ({state.Ops.Count} pending). " + + $"Terrain.activeTerrains={Terrain.activeTerrains.Length}, " + + $"building='{target.name}'"); + } + + /// + /// Called by each frame. + /// Returns true if the behaviour should keep running, false if it should self-destruct. + /// + internal static bool Tick(int id, string buildingName) + { + if (!States.TryGetValue(id, out var state) || state.Ops.Count == 0) + { + States.Remove(id); + return false; + } + + // Throttle to RetryInterval using unscaledTime (independent of timeScale) + if (Time.unscaledTime < state.NextRetryTime) + return true; + + state.Attempts++; + state.NextRetryTime = Time.unscaledTime + RetryInterval; + + int terrainCount = Terrain.activeTerrains.Length; + + // Check retry limit + if (state.Attempts > MaxRetries) + { + DebugLog.Error($"[TerrainRetry] Gave up after {MaxRetries} attempts " + + $"({state.Ops.Count} ops remaining). " + + $"Terrain.activeTerrains={terrainCount}, " + + $"building='{buildingName}'"); + States.Remove(id); + return false; + } + + // Wait for terrain to be available + if (terrainCount == 0) + { + if (state.Attempts % 5 == 0) + { + DebugLog.Info($"[TerrainRetry] Waiting for terrain... " + + $"attempt {state.Attempts}/{MaxRetries}, " + + $"building='{buildingName}'"); + } + return true; + } + + // Terrain is available — execute all pending operations + DebugLog.Info($"[TerrainRetry] Terrain available ({terrainCount} active). " + + $"Executing {state.Ops.Count} deferred ops after {state.Attempts} attempt(s)."); + + for (int i = state.Ops.Count - 1; i >= 0; i--) + { + try + { + if (state.Ops[i]()) + { + state.Ops.RemoveAt(i); + } + else + { + DebugLog.Warning("[TerrainRetry] Operation returned false " + + "despite terrain being available — removing."); + state.Ops.RemoveAt(i); + } + } + catch (Exception ex) + { + DebugLog.Error($"[TerrainRetry] Operation threw: {ex.Message}\n{ex.StackTrace}"); + state.Ops.RemoveAt(i); + } + } + + if (state.Ops.Count == 0) + { + DebugLog.Info("[TerrainRetry] All deferred terrain operations completed."); + States.Remove(id); + return false; + } + + return true; + } + + /// Cleanup state for a destroyed behaviour. + internal static void Remove(int id) + { + States.Remove(id); + } + } + + /// + /// Attached to a building root to retry terrain operations that failed + /// because terrain wasn't loaded yet (common on multiplayer clients). + /// Polls until is non-empty, + /// then executes queued operations and removes itself. + /// + /// This MonoBehaviour has NO Func or generic delegate parameters + /// on any method, because IL2CPP ClassInjector cannot marshal them. + /// All delegate storage lives in . + /// + /// + internal sealed class TerrainRetryBehaviour : MonoBehaviour + { +#if IL2CPP + private static bool _registered; +#endif + + /// + /// Ensure IL2CPP type registration, then add or get the component on the target. + /// + internal static TerrainRetryBehaviour EnsureOn(GameObject target) + { +#if IL2CPP + if (!_registered) + { + ClassInjector.RegisterTypeInIl2Cpp(); + _registered = true; + } +#endif + var existing = target.GetComponent(); + return existing != null ? existing : target.AddComponent(); + } + + private void Update() + { + if (!TerrainRetryQueue.Tick(GetInstanceID(), gameObject.name)) + { + Destroy(this); + } + } + + private void OnDestroy() + { + TerrainRetryQueue.Remove(GetInstanceID()); + } + } +} From c705123c928fafd83052b791b0cb59b50f1338ec Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 13:58:11 -0400 Subject: [PATCH 36/64] feat(Building): add client-side FishNet prefab linking and inactive spawn flow --- Building/Components/NetworkedPrefabLinker.cs | 280 +++++++++++++++++++ Building/Components/PrefabPlacer.cs | 176 ++++++++---- Core/PrefabRef.cs | 80 ++++-- 3 files changed, 466 insertions(+), 70 deletions(-) create mode 100644 Building/Components/NetworkedPrefabLinker.cs diff --git a/Building/Components/NetworkedPrefabLinker.cs b/Building/Components/NetworkedPrefabLinker.cs new file mode 100644 index 0000000..ca12437 --- /dev/null +++ b/Building/Components/NetworkedPrefabLinker.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using S1MAPI.Utils; +using UnityEngine; +using UnityEngine.SceneManagement; + +#if IL2CPP +using Il2CppInterop.Runtime.Injection; +#endif + +namespace S1MAPI.Building.Components +{ + /// + /// Static operations queue for deferred client-side linking of FishNet-replicated prefabs. + /// When a networked prefab is spawned on the server, FishNet replicates it to clients + /// but does NOT replicate the parent hierarchy. This queue finds the replicated object + /// on the client and parents it to the correct building transform. + /// + /// Separated from so that generic delegates + /// never appear on the ClassInjector-registered MonoBehaviour. + /// + /// + internal static class NetworkedPrefabLinkerQueue + { + private const float PollInterval = 0.5f; + private const int MaxAttempts = 60; // 30 seconds total + private const float PositionTolerance = 1.0f; + + private sealed class LinkRequest + { + public string PrefabName; + public Vector3 ExpectedWorldPos; + public Transform Parent; + public Vector3 LocalPosition; + public Quaternion LocalRotation; + public Action? OnLinked; + + public LinkRequest(string prefabName, Vector3 expectedWorldPos, Transform parent, + Vector3 localPosition, Quaternion localRotation, Action? onLinked) + { + PrefabName = prefabName; + ExpectedWorldPos = expectedWorldPos; + Parent = parent; + LocalPosition = localPosition; + LocalRotation = localRotation; + OnLinked = onLinked; + } + } + + private sealed class LinkerState + { + public readonly List Requests = new List(); + public int Attempts; + public float NextPollTime; + } + + private static readonly Dictionary States = new Dictionary(); + + /// + /// Queue a link request. Immediately scans scene root objects for an already-replicated + /// match (handles the case where FishNet delivered the object before this call). + /// If not found, a is attached to the + /// target to drive polling until the replicated object arrives. + /// + internal static void Enqueue(GameObject target, string prefabName, Vector3 expectedWorldPos, + Transform parent, Vector3 localPosition, Quaternion localRotation, + Action? onLinked = null) + { + var request = new LinkRequest(prefabName, expectedWorldPos, parent, + localPosition, localRotation, onLinked); + + // Immediate scan — the object may already exist if FishNet replicated before this call + if (TryMatchAndLink(request, 0)) + { + DebugLog.Info($"[PrefabLinker] Linked '{prefabName}' to '{parent.name}' " + + "immediately (already replicated)."); + return; + } + + // Not found yet — attach behaviour and poll + var behaviour = NetworkedPrefabLinkerBehaviour.EnsureOn(target); + int id = behaviour.GetInstanceID(); + + if (!States.TryGetValue(id, out var state)) + { + state = new LinkerState { NextPollTime = Time.unscaledTime + PollInterval }; + States[id] = state; + } + + state.Requests.Add(request); + + DebugLog.Info($"[PrefabLinker] Queued link for '{prefabName}' " + + $"({state.Requests.Count} pending). building='{target.name}'"); + } + + /// + /// Called by each frame. + /// Returns true to keep polling, false to self-destruct. + /// + internal static bool Tick(int id, string buildingName) + { + if (!States.TryGetValue(id, out var state) || state.Requests.Count == 0) + { + States.Remove(id); + return false; + } + + if (Time.unscaledTime < state.NextPollTime) + return true; + + state.Attempts++; + state.NextPollTime = Time.unscaledTime + PollInterval; + + if (state.Attempts > MaxAttempts) + { + DebugLog.Warning($"[PrefabLinker] Gave up after {MaxAttempts} attempts " + + $"({state.Requests.Count} unresolved). building='{buildingName}'"); + States.Remove(id); + return false; + } + + for (int i = state.Requests.Count - 1; i >= 0; i--) + { + var req = state.Requests[i]; + + if (req.Parent == null) + { + state.Requests.RemoveAt(i); + continue; + } + + if (TryMatchAndLink(req, state.Attempts)) + { + state.Requests.RemoveAt(i); + } + else if (state.Attempts % 10 == 0) + { + DebugLog.Info($"[PrefabLinker] Still waiting for '{req.PrefabName}'... " + + $"attempt {state.Attempts}/{MaxAttempts}"); + } + } + + if (state.Requests.Count == 0) + { + DebugLog.Info("[PrefabLinker] All pending links resolved."); + States.Remove(id); + return false; + } + + return true; + } + + /// + /// Scan scene root objects for an unparented object matching the request's + /// prefab name and expected world position. If found, parent it and invoke the callback. + /// + private static bool TryMatchAndLink(LinkRequest req, int attempts) + { + GameObject[] rootObjects; + try + { + rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); + } + catch (Exception) + { + return false; + } + + // Two-pass matching: first try name + position (precise), then name-only (fallback). + // FishNet may replicate objects at incorrect world positions (e.g., origin) when the + // server sets transform on an inactive object before Spawn(). The name-only pass + // catches these stranded objects and parents them correctly using stored local coords. + GameObject? bestMatch = null; + float bestDist = float.MaxValue; + + foreach (var obj in rootObjects) + { + if (obj == null) continue; + if (!obj.name.Contains(req.PrefabName)) continue; + + float dist = Vector3.Distance(obj.transform.position, req.ExpectedWorldPos); + + // Precise match — take immediately + if (dist <= PositionTolerance) + { + bestMatch = obj; + bestDist = dist; + break; + } + + // Name-only fallback — pick closest among stranded objects + if (dist < bestDist) + { + bestMatch = obj; + bestDist = dist; + } + } + + if (bestMatch != null) + { + if (bestDist > PositionTolerance) + { + DebugLog.Info($"[PrefabLinker] Name-only match for '{bestMatch.name}' " + + $"(dist={bestDist:F1}m, expected within {PositionTolerance:F1}m). " + + $"FishNet likely spawned at wrong position."); + } + + bestMatch.transform.SetParent(req.Parent); + bestMatch.transform.localPosition = req.LocalPosition; + bestMatch.transform.localRotation = req.LocalRotation; + + if (attempts > 0) + { + DebugLog.Info($"[PrefabLinker] Linked '{bestMatch.name}' to '{req.Parent.name}' " + + $"after {attempts} poll(s)."); + } + + try + { + req.OnLinked?.Invoke(bestMatch); + } + catch (Exception ex) + { + DebugLog.Error($"[PrefabLinker] OnLinked callback threw: {ex.Message}"); + } + + return true; + } + + return false; + } + + /// Cleanup state for a destroyed behaviour. + internal static void Remove(int id) + { + States.Remove(id); + } + } + + /// + /// Attached to a building root on clients to poll for FishNet-replicated prefabs + /// and parent them into the building hierarchy once they arrive. + /// + /// This MonoBehaviour has NO generic delegate parameters — all delegate storage + /// lives in . + /// + /// + internal sealed class NetworkedPrefabLinkerBehaviour : MonoBehaviour + { +#if IL2CPP + private static bool _registered; +#endif + + internal static NetworkedPrefabLinkerBehaviour EnsureOn(GameObject target) + { +#if IL2CPP + if (!_registered) + { + ClassInjector.RegisterTypeInIl2Cpp(); + _registered = true; + } +#endif + var existing = target.GetComponent(); + return existing != null ? existing : target.AddComponent(); + } + + private void Update() + { + if (!NetworkedPrefabLinkerQueue.Tick(GetInstanceID(), gameObject.name)) + { + Destroy(this); + } + } + + private void OnDestroy() + { + NetworkedPrefabLinkerQueue.Remove(GetInstanceID()); + } + } +} diff --git a/Building/Components/PrefabPlacer.cs b/Building/Components/PrefabPlacer.cs index 27282d6..5c10c49 100644 --- a/Building/Components/PrefabPlacer.cs +++ b/Building/Components/PrefabPlacer.cs @@ -1,15 +1,15 @@ +using System; using UnityEngine; using S1MAPI.Core; using S1MAPI.Extensions; using S1MAPI.S1; +using S1MAPI.Utils; #if IL2CPP using Il2CppFishNet; -using Il2CppFishNet.Managing; -using Il2CppFishNet.Managing.Object; -using Il2CppFishNet.Object; using Il2CppTMPro; #else +using FishNet; using TMPro; #endif @@ -45,85 +45,81 @@ public PrefabPlacer(Transform parent) /// /// Place a prefab at the specified position. + /// When is true, the prefab is spawned via FishNet + /// on the server and automatically replicated to clients. On clients, returns null + /// but queues a deferred link so the replicated object is parented to the building + /// once it arrives via FishNet. /// /// Prefab reference from GamePrefabs /// Local position relative to parent /// Local rotation - /// Whether to spawn on network (server only) + /// Whether to spawn on network (server only). When true, + /// returns null on clients — the server-spawned object is replicated via FishNet + /// and automatically parented to the building hierarchy. /// Whether to enable MonoBehaviour components (default: false) - /// The instantiated prefab or null if not found - public GameObject? Place(PrefabRef prefab, Vector3 localPosition, Quaternion localRotation, bool networked = true, bool enableComponents = false) + /// Optional callback invoked on the GameObject after instantiation (server) + /// or after the FishNet-replicated object is linked (client). Use this to configure + /// components before sensors/triggers activate (e.g., set DoorController.AutoOpenForPlayer). + /// The instantiated prefab, or null if not found or if networked and not server + public GameObject? Place(PrefabRef prefab, Vector3 localPosition, Quaternion localRotation, bool networked = true, bool enableComponents = false, Action? onReady = null) { - GameObject? instance = networked ? prefab.InstantiateNetworked() : prefab.Instantiate(); - if (instance == null) return null; - - instance.transform.SetParent(_parent); - instance.transform.localPosition = localPosition; - instance.transform.localRotation = localRotation; - - // Optionally enable game logic components - if (enableComponents) + Action? combined = null; + if (enableComponents && onReady != null) { - instance.EnableAllComponents(recursive: true); + combined = (go) => { go.EnableAllComponents(recursive: true); onReady(go); }; } - - return instance; + else if (enableComponents) + { + combined = (go) => go.EnableAllComponents(recursive: true); + } + else + { + combined = onReady; + } + return PlaceInternal(prefab, localPosition, localRotation, networked, combined); } /// /// Place a prefab with specific components enabled by name. /// Useful for enabling only certain game logic (e.g., ATM, VendingMachine). + /// When is true, returns null on non-server callers + /// but the replicated object is automatically parented on clients. /// /// Prefab reference from GamePrefabs /// Local position relative to parent /// Local rotation /// Array of component type names to enable - /// Whether to spawn on network (server only) - /// The instantiated prefab or null if not found + /// Whether to spawn on network (server only). When true, + /// returns null on clients — the server-spawned object is replicated via FishNet + /// and automatically parented to the building hierarchy. + /// The instantiated prefab, or null if not found or if networked and not server public GameObject? PlaceWithComponents(PrefabRef prefab, Vector3 localPosition, Quaternion localRotation, string[] componentNames, bool networked = true) { - GameObject? instance = networked ? prefab.InstantiateNetworked() : prefab.Instantiate(); - if (instance == null) return null; - - instance.transform.SetParent(_parent); - instance.transform.localPosition = localPosition; - instance.transform.localRotation = localRotation; - - // Enable specific components by name - instance.EnableComponentsByName(componentNames, recursive: true); - - return instance; + string[] names = componentNames; + return PlaceInternal(prefab, localPosition, localRotation, networked, + (go) => go.EnableComponentsByName(names, recursive: true)); } /// /// Place sliding double doors at a wall opening. + /// On server, spawns the door and applies customization (material, opening hours text). + /// On clients, returns null but queues a deferred link so the FishNet-replicated door + /// is parented to the building and receives the same customization once it arrives. + /// See remarks for DoorController + /// server-gating details. /// /// Local position for the doors /// Local rotation /// Text to display for opening hours /// Optional material for door panels - /// The door instance or null if prefab not found + /// The door instance on server, or null on clients / if prefab not found public GameObject? PlaceSlidingDoors(Vector3 localPosition, Quaternion localRotation, string openingHoursText = "6AM-6PM", Material? doorMaterial = null) { - GameObject? doors = Place(Prefabs.SlidingDoors, localPosition, localRotation, networked: true); - if (doors == null) return null; - - doors.name = "SlidingDoors"; + Material? mat = doorMaterial; + string text = openingHoursText; - // Apply material to door panels - if (doorMaterial != null) - { - ApplyMaterialToPath(doors, "Door/Door", doorMaterial); - ApplyMaterialToPath(doors, "Door/Door (1)", doorMaterial); - } - - // Set opening hours text - if (!string.IsNullOrEmpty(openingHoursText)) - { - SetOpeningHoursText(doors, openingHoursText); - } - - return doors; + return PlaceInternal(Prefabs.SlidingDoors, localPosition, localRotation, networked: true, + (go) => CustomizeSlidingDoors(go, mat, text)); } /// @@ -168,6 +164,86 @@ public PrefabPlacer(Transform parent) #region Private Methods + /// + /// Core placement logic shared by all public Place methods. + /// On server: instantiates, parents, invokes onReady, returns instance. + /// On client (networked): queues a deferred link via NetworkedPrefabLinkerQueue + /// so the FishNet-replicated object is parented and customized when it arrives. + /// + private GameObject? PlaceInternal(PrefabRef prefab, Vector3 localPosition, Quaternion localRotation, + bool networked, Action? onReady) + { + // For networked prefabs, instantiate WITHOUT activating so onReady can + // configure components before Awake/OnEnable fire. This prevents sensors + // (e.g., DoorSensor) from triggering with prefab-default values. + GameObject? instance = networked + ? prefab.InstantiateNetworkedInactive(_parent, localPosition, localRotation) + : prefab.Instantiate(); + + if (instance == null) + { + if (!networked) + { + DebugLog.Warning($"[PrefabPlacer] '{prefab.Name}' local instantiate returned null."); + return null; + } + + if (_parent == null) + { + DebugLog.Warning($"[PrefabPlacer] '{prefab.Name}' returned null and parent is destroyed — cannot queue link."); + return null; + } + + // On a connected client, queue a link so the FishNet-replicated + // object gets parented to the building once it arrives. + var nm = InstanceFinder.NetworkManager; + if (nm != null && nm.IsClient) + { + Vector3 expectedWorldPos = _parent.TransformPoint(localPosition); + NetworkedPrefabLinkerQueue.Enqueue( + _parent.gameObject, prefab.Name, expectedWorldPos, + _parent, localPosition, localRotation, onReady); + } + else + { + DebugLog.Warning($"[PrefabPlacer] '{prefab.Name}' returned null — " + + $"nm={nm != null}, isClient={nm?.IsClient}, isServer={nm?.IsServer}"); + } + return null; + } + + if (!networked) + { + instance.transform.SetParent(_parent); + instance.transform.localPosition = localPosition; + instance.transform.localRotation = localRotation; + } + + onReady?.Invoke(instance); + + // Activate AFTER onReady so sensors/triggers see configured values, not prefab defaults. + if (networked && !instance.activeSelf) + instance.SetActive(true); + + return instance; + } + + private static void CustomizeSlidingDoors(GameObject doors, Material? doorMaterial, string? openingHoursText) + { + doors.name = "SlidingDoors"; + + if (doorMaterial != null) + { + ApplyMaterialToPath(doors, "Door/Door", doorMaterial); + ApplyMaterialToPath(doors, "Door/Door (1)", doorMaterial); + } + + if (!string.IsNullOrEmpty(openingHoursText)) + { + SetOpeningHoursText(doors, openingHoursText); + } + } + private static void ApplyMaterialToPath(GameObject root, string path, Material material) { Transform t = root.transform.Find(path); diff --git a/Core/PrefabRef.cs b/Core/PrefabRef.cs index 3b119e4..7289411 100644 --- a/Core/PrefabRef.cs +++ b/Core/PrefabRef.cs @@ -84,7 +84,7 @@ public PrefabRef(string name) /// /// WARNING: Only use this method for prefabs that do NOT have a NetworkObject component. /// - /// For prefabs with NetworkObject components (networked prefabs), you MUST use instead. + /// For prefabs with NetworkObject components (networked prefabs), you MUST use instead. /// Using this method on networked prefabs will cause FishNet to crash and break multiplayer functionality. /// /// @@ -139,6 +139,43 @@ public PrefabRef(string name) /// Thrown if called on client when not server public GameObject? InstantiateNetworked() { + return InstantiateNetworkedCore(null, Vector3.zero, Quaternion.identity, activate: true); + } + + /// + /// Instantiate and spawn a networked prefab, positioned before network spawn. + /// Sets the transform BEFORE calling FishNet Spawn() so that clients receive + /// the correct world position via replication (critical for prefabs without NetworkTransform). + /// + /// Parent transform (set before spawn) + /// Local position relative to parent + /// Local rotation relative to parent + /// The instantiated and network-spawned GameObject, or null if not server or prefab not found + public GameObject? InstantiateNetworked(Transform parent, Vector3 localPosition, Quaternion localRotation) + { + return InstantiateNetworkedCore(parent, localPosition, localRotation, activate: true); + } + + /// + /// Instantiate and spawn a networked prefab without activating it. + /// The caller is responsible for calling SetActive(true) after configuration. + /// Used by to invoke onReady callbacks + /// before Awake/OnEnable fire (prevents sensors from triggering with default config). + /// + internal GameObject? InstantiateNetworkedInactive(Transform parent, Vector3 localPosition, Quaternion localRotation) + { + return InstantiateNetworkedCore(parent, localPosition, localRotation, activate: false); + } + + private GameObject? InstantiateNetworkedCore(Transform? parent, Vector3 localPosition, Quaternion localRotation, bool activate) + { + var nm = InstanceFinder.NetworkManager; + if (nm == null || !nm.IsServer) + { + DebugLog.Warning($"[PrefabRef] InstantiateNetworked called but not server — skipping '{Name}'."); + return null; + } + var prefab = Find(); if (prefab == null) { @@ -146,37 +183,40 @@ public PrefabRef(string name) return null; } - // Store original active state bool originalState = prefab.activeSelf; - - // Temporarily disable the prefab to prevent Awake() during instantiation prefab.SetActive(false); - - // Instantiate with components inactive GameObject? instance = UnityEngine.Object.Instantiate(prefab); - - // Restore prefab's original state prefab.SetActive(originalState); if (instance == null) return null; - // Initialize GUID fields on components before Awake() runs - // This fixes prefabs like ATM that parse GUIDs in Awake() InitializeGuidFields(instance); - // Spawn on network (assigns network GUID and calls OnStartServer) - if (InstanceFinder.NetworkManager != null && InstanceFinder.NetworkManager.IsServer) + // Set world position before Spawn() so FishNet broadcasts the correct transform. + // Parent AFTER Spawn to avoid issues with non-networked parent hierarchies. + if (parent != null) { - var netObj = instance.GetComponent(); - if (netObj != null) - { - InstanceFinder.NetworkManager.ServerManager.Spawn(netObj); - } + instance.transform.position = parent.TransformPoint(localPosition); + instance.transform.rotation = parent.rotation * localRotation; } - // Now activate the instance - Awake() will run with valid GUIDs - instance.SetActive(true); - + var netObj = instance.GetComponent(); + if (netObj != null) + { + nm.ServerManager.Spawn(netObj); + } + + // Parent after spawn (local operation, not replicated) + if (parent != null) + { + instance.transform.SetParent(parent); + instance.transform.localPosition = localPosition; + instance.transform.localRotation = localRotation; + } + + if (activate) + instance.SetActive(true); + return instance; } From 31de7a7438d35aebaaec0473e7f34fdb47e346e8 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 14:05:27 -0400 Subject: [PATCH 37/64] feat(Building): add BuildingPartRegistry and document multiplayer door behavior --- Building/BuildingBuilder.cs | 115 +++++++++++++++++++----- Building/BuildingPartRegistry.cs | 145 +++++++++++++++++++++++++++++++ Building/Config/BuildingPart.cs | 30 +++++++ 3 files changed, 270 insertions(+), 20 deletions(-) create mode 100644 Building/BuildingPartRegistry.cs create mode 100644 Building/Config/BuildingPart.cs diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index ce979a9..bc652f0 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -33,6 +33,9 @@ public sealed class BuildingBuilder private BuildingConfig _config; private Vector3 _roomSize; + // Part registry for post-build targeting + private readonly BuildingPartRegistry _registry; + // Lazy-initialized builders private WallBuilder? _wallBuilder; private FurnitureBuilder? _furnitureBuilder; @@ -68,6 +71,7 @@ public BuildingBuilder(string name) { _name = name; _root = new GameObject(name); + _registry = _root.AddComponent(); _config = BuildingConfig.Default; _roomSize = _config.Size; } @@ -143,7 +147,8 @@ public BuildingBuilder AddFloor(Color? color = null, Material? material = null) palette.FloorMaterial = material; } - GetDecorBuilder(palette).AddFloor(_config.FloorThickness); + var floor = GetDecorBuilder(palette).AddFloor(_config.FloorThickness); + _registry.Register(BuildingPart.Floor, floor); return this; } @@ -163,7 +168,8 @@ public BuildingBuilder AddCeiling(Color? color = null, Material? material = null if (material != null) palette.CeilingMaterial = material; } - GetDecorBuilder(palette).AddCeiling(_config.CeilingThickness); + var ceiling = GetDecorBuilder(palette).AddCeiling(_config.CeilingThickness); + _registry.Register(BuildingPart.Ceiling, ceiling); return this; } @@ -219,11 +225,12 @@ public BuildingBuilder AddWalls( : (westWindow ? WallOpening.Window() : null); var builder = GetWallBuilder(palette); - builder.BuildWalls( + var wallsContainer = builder.BuildWalls( northOpening: _northOpening, southOpening: _southOpening, eastOpening: _eastOpening, westOpening: _westOpening); + RegisterWallChildren(wallsContainer); return this; } @@ -247,7 +254,8 @@ public BuildingBuilder AddWalls( _eastOpening = east; _westOpening = west; - GetWallBuilder().BuildWalls(north, south, east, west); + var wallsContainer = GetWallBuilder().BuildWalls(north, south, east, west); + RegisterWallChildren(wallsContainer); return this; } @@ -273,7 +281,8 @@ public BuildingBuilder AddWalls( _eastOpening = east; _westOpening = west; - GetWallBuilder().BuildWalls(north, south, east, west, wallAppearances); + var wallsContainer = GetWallBuilder().BuildWalls(north, south, east, west, wallAppearances); + RegisterWallChildren(wallsContainer); return this; } @@ -311,7 +320,9 @@ public BuildingBuilder AddInteriorWall( Color? color = null, Material? material = null) { var def = new InteriorWallDefinition(axis, position, from, to, opening, color, material); - GetInteriorWallBuilder().BuildInteriorWall(def); + var wall = GetInteriorWallBuilder().BuildInteriorWall(def); + if (wall != null) + _registry.Register(BuildingPart.InteriorWalls, wall); return this; } @@ -403,7 +414,8 @@ public BuildingBuilder FlattenTerrain( /// This builder for chaining public BuildingBuilder AddRoofTrim(float height = 0.3f, Material? material = null) { - GetDecorBuilder().AddRoofTrim(height, material); + var trim = GetDecorBuilder().AddRoofTrim(height, material); + _registry.Register(BuildingPart.Trim, trim); return this; } @@ -417,7 +429,8 @@ public BuildingBuilder AddRoofTrim(float height = 0.3f, Material? material = nul /// This builder for chaining public BuildingBuilder AddSecondaryRoofTrim(float height = 0.15f, Material? material = null) { - GetDecorBuilder().AddSecondaryRoofTrim(height, material); + var trim = GetDecorBuilder().AddSecondaryRoofTrim(height, material); + _registry.Register(BuildingPart.Accent, trim); return this; } @@ -450,8 +463,9 @@ public BuildingBuilder AddParapetRoof( Color? capColor = null, Material? capMaterial = null) { - GetRoofBuilder().AddParapetRoof(preset, parapetHeight, parapetDepth, + var roof = GetRoofBuilder().AddParapetRoof(preset, parapetHeight, parapetDepth, capHeight, capOverhang, parapetColor, parapetMaterial, capColor, capMaterial); + _registry.Register(BuildingPart.Roof, roof); return this; } @@ -478,8 +492,9 @@ public BuildingBuilder AddHipRoof( Material? roofMaterial = null, float baseSlabHeight = Constants.Roof.DefaultBaseSlabHeight) { - GetRoofBuilder().AddHipRoof(ridgeHeight, overhang, ridgeAlongX, + var roof = GetRoofBuilder().AddHipRoof(ridgeHeight, overhang, ridgeAlongX, roofColor, roofMaterial, baseSlabHeight); + _registry.Register(BuildingPart.Roof, roof); return this; } @@ -491,7 +506,8 @@ public BuildingBuilder AddHipRoof( /// This builder for chaining public BuildingBuilder AddCornerPillars(float width = 0.4f, Material? material = null) { - GetDecorBuilder().AddCornerPillars(width, material); + var pillars = GetDecorBuilder().AddCornerPillars(width, material); + _registry.Register(BuildingPart.Pillars, pillars); return this; } @@ -506,7 +522,8 @@ public BuildingBuilder AddCornerPillars(float width = 0.4f, Material? material = /// This builder for chaining public BuildingBuilder AddCornerTrim(float width = 0.3f, float depth = 0.1f, Material? material = null) { - GetDecorBuilder().AddCornerTrim(width, depth, material); + var cornerTrim = GetDecorBuilder().AddCornerTrim(width, depth, material); + _registry.Register(BuildingPart.Trim, cornerTrim); return this; } @@ -522,7 +539,8 @@ public BuildingBuilder AddCornerTrim(float width = 0.3f, float depth = 0.1f, Mat public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, float expandZ = 0f, Color? color = null, Material? material = null) { _foundationHeight = height; - GetDecorBuilder().AddFoundation(height, expandX, expandZ, color, material); + var foundation = GetDecorBuilder().AddFoundation(height, expandX, expandZ, color, material); + _registry.Register(BuildingPart.Foundation, foundation); return this; } @@ -557,7 +575,8 @@ public BuildingBuilder AddStairs( { float lateralOffset = GetDoorOffset(wall); _stairs.Add((wall, foundationHeight, width, lateralOffset)); - GetDecorBuilder().AddStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, style, flushWithFloor, gap, lateralOffset); + var stairs = GetDecorBuilder().AddStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, style, flushWithFloor, gap, lateralOffset); + _registry.Register(BuildingPart.Stairs, stairs); return this; } @@ -569,8 +588,9 @@ public BuildingBuilder AddStairs( /// This builder for chaining public BuildingBuilder AddDoorFrames(Material? material = null) { - GetDecorBuilder().AddDoorFrames( + var frames = GetDecorBuilder().AddDoorFrames( _northOpening, _southOpening, _eastOpening, _westOpening, material); + _registry.Register(BuildingPart.Trim, frames); return this; } @@ -583,7 +603,10 @@ public BuildingBuilder AddDoorFrames(Material? material = null) public BuildingBuilder AddInteriorDoorFrames(Material? material = null) { if (_interiorWallBuilder != null && _interiorWallBuilder.Doorways.Count > 0) - GetDecorBuilder().AddInteriorDoorFrames(_interiorWallBuilder.Doorways, material: material); + { + var frames = GetDecorBuilder().AddInteriorDoorFrames(_interiorWallBuilder.Doorways, material: material); + _registry.Register(BuildingPart.Trim, frames); + } return this; } @@ -598,8 +621,9 @@ public BuildingBuilder AddInteriorDoorFrames(Material? material = null) public BuildingBuilder AddBaseMolding(float height = 0.3f, float depth = 0.1f, Material? material = null, IEnumerable? skipWalls = null) { - GetDecorBuilder().AddBaseMolding(height, depth, material, + var molding = GetDecorBuilder().AddBaseMolding(height, depth, material, _northOpening, _southOpening, _eastOpening, _westOpening, skipWalls); + _registry.Register(BuildingPart.Trim, molding); return this; } @@ -685,12 +709,36 @@ public BuildingBuilder AddPrefab(PrefabRef prefab, Vector3 position, Quaternion } /// - /// Add sliding double doors at a door opening. - /// + /// Add sliding double doors at a door opening. Server only — returns the builder + /// unchanged on clients (the door is replicated via FishNet). + /// + /// + /// Multiplayer behavior: + /// + /// Server/host: Instantiates and network-spawns the door. + /// FishNet replicates it to all clients. The callback fires. + /// Client: Returns immediately — the door is not created locally. + /// The server-spawned door arrives via FishNet replication and is automatically parented + /// to the building hierarchy with material/text customization applied. + /// + /// DoorController server-gating: The game's DoorController gates + /// ALL proximity sensor callbacks (PlayerVicinityEnter/Exit, NPCVicinityEnter/Exit) and + /// auto-close logic behind InstanceFinder.IsServer. This means on clients: + /// + /// Doors will NOT auto-open when players/NPCs approach + /// Doors will NOT auto-close when players/NPCs leave + /// Manual interaction (clicking the door handle) DOES work via ServerRpc + /// + /// S1MAPI cannot implement client-side auto-open/close because it requires game assembly + /// types (DoorController, EDoorSide, Player) that S1MAPI intentionally does not reference. + /// Consumer mods that need this behavior must implement their own proximity polling + /// using DoorController.SetIsOpen_Server (public, RequireOwnership=false, RunLocally=true). + /// /// Local position for doors /// Local rotation /// Text for opening hours sign - /// Optional callback invoked with the instantiated door GameObject + /// Optional callback invoked with the instantiated door GameObject. + /// Only fires on the server — will NOT fire on clients. /// This builder for chaining public BuildingBuilder AddSlidingDoors(Vector3 position, Quaternion rotation, string openingHours = "6AM-6PM", Action? onCreated = null) { @@ -743,6 +791,11 @@ public GameObject Build(Action postBuild) /// public BuildingConfig Config => _config; + /// + /// Post-build registry for targeting specific building parts (walls, floor, trim, etc.). + /// + public BuildingPartRegistry Registry => _registry; + /// /// Grid cell size computed from room dimensions. /// Furniture placement and interior pathfinding both use this value @@ -820,6 +873,28 @@ private float GetDoorOffset(WallSide wall) return opening?.Offset ?? 0f; } + private void RegisterWallChildren(GameObject wallsContainer) + { + for (int i = 0; i < wallsContainer.transform.childCount; i++) + { + Transform child = wallsContainer.transform.GetChild(i); + WallSide? side = ParseWallSide(child.name); + if (side.HasValue) + _registry.Register(side.Value, child.gameObject); + else + _registry.Register(BuildingPart.ExteriorWalls, child.gameObject); + } + } + + private static WallSide? ParseWallSide(string name) + { + if (name.StartsWith("North")) return WallSide.North; + if (name.StartsWith("South")) return WallSide.South; + if (name.StartsWith("East")) return WallSide.East; + if (name.StartsWith("West")) return WallSide.West; + return null; + } + #endregion #region Private Methods - NavMesh diff --git a/Building/BuildingPartRegistry.cs b/Building/BuildingPartRegistry.cs new file mode 100644 index 0000000..46b32b0 --- /dev/null +++ b/Building/BuildingPartRegistry.cs @@ -0,0 +1,145 @@ +using System.Collections.Generic; +using S1MAPI.Building.Config; +using S1MAPI.Building.Structural; +using UnityEngine; + +namespace S1MAPI.Building +{ + /// + /// Post-build registry that tracks building parts by category. + /// Attached to the building root during construction by . + /// Consumers use this to target specific parts (all exterior walls, just the north wall, + /// the floor, trim, etc.) and get their renderers for material swaps. + /// + public sealed class BuildingPartRegistry : MonoBehaviour + { + private readonly Dictionary> _parts = new Dictionary>(); + private readonly Dictionary> _wallsByDirection = new Dictionary>(); + + #region Registration (called by BuildingBuilder during construction) + + internal void Register(BuildingPart part, GameObject go) + { + if (!_parts.TryGetValue(part, out var list)) + { + list = new List(); + _parts[part] = list; + } + list.Add(go); + } + + internal void Register(WallSide side, GameObject go) + { + if (!_wallsByDirection.TryGetValue(side, out var list)) + { + list = new List(); + _wallsByDirection[side] = list; + } + list.Add(go); + + Register(BuildingPart.ExteriorWalls, go); + } + + #endregion + + #region Consumer API — Renderers + + /// + /// Get all renderers for a building part category. + /// + public Renderer[] GetRenderers(BuildingPart part) + { + if (!_parts.TryGetValue(part, out var list)) + return System.Array.Empty(); + + var renderers = new List(); + for (int i = 0; i < list.Count; i++) + { + if (list[i] != null) + renderers.AddRange(list[i].GetComponentsInChildren()); + } + return renderers.ToArray(); + } + + /// + /// Get all renderers for a specific wall direction. + /// + public Renderer[] GetRenderers(WallSide side) + { + if (!_wallsByDirection.TryGetValue(side, out var list)) + return System.Array.Empty(); + + var renderers = new List(); + for (int i = 0; i < list.Count; i++) + { + if (list[i] != null) + renderers.AddRange(list[i].GetComponentsInChildren()); + } + return renderers.ToArray(); + } + + #endregion + + #region Consumer API — GameObjects + + /// + /// Get all GameObjects for a building part category. + /// + public GameObject[] GetParts(BuildingPart part) + { + if (!_parts.TryGetValue(part, out var list)) + return System.Array.Empty(); + + var result = new List(list.Count); + for (int i = 0; i < list.Count; i++) + { + if (list[i] != null) + result.Add(list[i]); + } + return result.ToArray(); + } + + /// + /// Get all GameObjects for a specific wall direction. + /// + public GameObject[] GetParts(WallSide side) + { + if (!_wallsByDirection.TryGetValue(side, out var list)) + return System.Array.Empty(); + + var result = new List(list.Count); + for (int i = 0; i < list.Count; i++) + { + if (list[i] != null) + result.Add(list[i]); + } + return result.ToArray(); + } + + #endregion + + #region Consumer API — Material swap + + /// + /// Set the material on all renderers for a building part category. + /// + public void SetMaterial(BuildingPart part, Material material) + { + var renderers = GetRenderers(part); + for (int i = 0; i < renderers.Length; i++) + renderers[i].material = material; + } + + /// + /// Set the material on all renderers for a specific wall direction. + /// + public void SetMaterial(WallSide side, Material material) + { + var renderers = GetRenderers(side); + for (int i = 0; i < renderers.Length; i++) + renderers[i].material = material; + } + + #endregion + } +} diff --git a/Building/Config/BuildingPart.cs b/Building/Config/BuildingPart.cs new file mode 100644 index 0000000..f33662f --- /dev/null +++ b/Building/Config/BuildingPart.cs @@ -0,0 +1,30 @@ +namespace S1MAPI.Building.Config +{ + /// + /// Categories of building parts for post-build identification. + /// Aligns with material groups. + /// + public enum BuildingPart + { + /// All exterior wall segments (WallMaterial). + ExteriorWalls, + /// Floor slab (FloorMaterial). + Floor, + /// Ceiling slab (CeilingMaterial). + Ceiling, + /// Roof trim, corner trim, base molding, door frames (TrimMaterial). + Trim, + /// Corner pillars (PillarMaterial). + Pillars, + /// Secondary trim, parapet caps (AccentMaterial). + Accent, + /// Foundation block (FloorMaterial). + Foundation, + /// Parapet walls, hip roof slopes/slabs (WallMaterial). + Roof, + /// Stair geometry (FloorMaterial). + Stairs, + /// Interior wall segments (WallMaterial). + InteriorWalls + } +} From eddf87a1663178a6aec5e71551b98be123dcad50 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 14:09:34 -0400 Subject: [PATCH 38/64] docs: fix XML doc generic syntax in OrganicShapeGenerator example --- ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs b/ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs index d8e134a..dfe9932 100644 --- a/ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs +++ b/ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs @@ -16,7 +16,7 @@ namespace S1MAPI.ProceduralMesh.Generators.Organic /// /// public class PetMeshGenerator : OrganicShapeGenerator /// { - /// protected override void GenerateGeometry(List<Vector3> vertices, List<int> triangles) + /// protected override void GenerateGeometry(List{Vector3} vertices, List{int} triangles) /// { /// // Use helper methods like AddRing, AddJointRing, ConnectRings /// AddRing(vertices, position: Vector3.zero, radius: 1f, segments: 12); From a19bdd06b064e6144c6eb8f716fbaef9543278f7 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 14:31:41 -0400 Subject: [PATCH 39/64] docs: add advanced building example with terrain, navigation, and networked doors --- docs/examples.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/docs/examples.md b/docs/examples.md index 7de4646..7ec4938 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -173,6 +173,82 @@ interior.AddChair(new Vector3(4, 0, 4), Quaternion.Euler(0, 180, 0)); interior.Build(); ``` +### Advanced Building (Terrain, Navigation, & Networked Doors) + +This complete example demonstrates how to prep the environment using `TerrainClearer` and `FlattenTerrain`, utilize complex wall openings like door and window combinations, generate an interior A* `NavigationBuilder` for NPC pathfinding, and place multiplayer-synced interactables securely using `PrefabPlacer` connected to FishNet. + +```csharp +using S1MAPI.Building; +using S1MAPI.Building.Components; +using S1MAPI.Building.Config; +using S1MAPI.Building.Structural; +using S1MAPI.S1; +using S1MAPI.Utils; +using UnityEngine; + +public static class StorefrontSpawner +{ + private static NavigationBuilder _navBuilder; + + public static void SpawnStorefront(Vector3 position) + { + BuildingBuilder builder = new BuildingBuilder("CoffeeShop") + .DefineRoom(12f, 4f, 10f) + .AddFloor() + .AddCeiling() + // Storefront: Central door, flanking windows + .AddWalls( + south: WallOpening.DoorWithWindows( + doorWidth: 1.8f, doorHeight: 2.2f, + leftWindow: WallOpening.Window(width: 1.5f, height: 2.0f, sillHeight: 0.5f, count: 1), + rightWindow: WallOpening.Window(width: 1.5f, height: 2.0f, sillHeight: 0.5f, count: 1) + ), + north: null, east: null, west: null + ) + .AddFoundation(height: 0.2f) + .AddBaseMolding() + .AddCornerTrim() + // Backroom divider 6 meters inward along the Z-axis + .AddInteriorWall(InteriorWallAxis.X, 6f, opening: WallOpening.Door(1.2f, 2.1f)) + // Finish with a polished parapet style roof + .AddParapetRoof(ParapetPreset.Shallow); + + GameObject coffeeShop = builder.Build(); + coffeeShop.transform.position = position; + + // 1. Clear terrain foliage and trees that would clip through the shop + TerrainClearer.ClearAroundBuilding(coffeeShop, new Vector3(12f, 4f, 10f), new ClearingOptions { Padding = 2f }); + + // 2. Flatten the terrain directly underneath + builder.FlattenTerrain(); + + // 3. Build A* NPC navigation grid for the interior (Must scan around our created walls) + _navBuilder = builder.CreateNavigationBuilder(); + _navBuilder.Build(); + + // 4. Place networked doors via PrefabPlacer for FishNet multiplayer replication + PrefabPlacer placer = new PrefabPlacer(coffeeShop.transform); + + // Entrance door + placer.Place( + Prefabs.MetalGlassDoor, + new Vector3(6f, 0f, 0f), + Quaternion.identity, + networked: true, + enableComponents: true + ); + + // Interior backroom door + placer.Place( + Prefabs.ClassicalWoodenDoor, + new Vector3(6f, 0f, 6f), + Quaternion.identity, + networked: true + ); + } +} +``` + ## GLTF Examples ### Load from Embedded Resource From 010ef8dd6fa35ce476779b553f177c80e594c76b Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 14:36:12 -0400 Subject: [PATCH 40/64] docs: expand building guide with interiors, roofs, terrain, navigation, and part registry --- docs/building.md | 82 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/docs/building.md b/docs/building.md index d244ddd..505f713 100644 --- a/docs/building.md +++ b/docs/building.md @@ -112,15 +112,32 @@ new BuildingBuilder("Shop") ) ``` -### Custom Openings +### Custom Openings & Offsets + +You can combine doors and windows on the same wall, and offset them horizontally: ```csharp using S1MAPI.Building.Structural; .AddWalls( - north: WallOpening.Door(width: 1.5f, height: 2.5f), - south: WallOpening.Window(width: 2f, height: 1.5f), - east: WallOpening.Door() // Default door + north: WallOpening.DoorWithWindows( + doorWidth: 1.5f, doorHeight: 2.5f, + leftWindow: WallOpening.Window(width: 1f), + rightWindow: WallOpening.Window(width: 1f) + ), + south: WallOpening.Window(width: 2f, height: 1.5f) +) +``` + +### Interior Walls + +Segment your building by defining interior walls. Note that `InteriorWallAxis.X` walls run along the east-to-west axis, and `InteriorWallAxis.Z` walls run north-to-south. + +```csharp +.AddInteriorWall( + axis: InteriorWallAxis.X, + position: 4f, + opening: WallOpening.Door() ) ``` @@ -130,8 +147,39 @@ using S1MAPI.Building.Structural; .AddRoofTrim(height: 0.3f) // Roofline trim .AddSecondaryRoofTrim(height: 0.15f) // Secondary trim .AddCornerPillars(width: 0.5f) // Corner columns -.AddBaseMolding(height: 0.3f, depth: 0.1f) // Baseboard +.AddBaseMolding(height: 0.3f) // Baseboard .AddFoundation(height: 2.0f, expandX: 0.3f, expandZ: 0.3f) // Foundation +.AddStairs(WallSide.South, maxStepHeight: 0.2f, style: StairStyle.ClosedRiser) // Entry stairs +``` + +### Roofs + +Top off the building with generated roofs: + +```csharp +.AddParapetRoof(ParapetPreset.Shallow) // Flat roof with parapet walls +.AddHipRoof(ridgeHeight: 2.0f, overhang: 0.3f) // Sloped hip roof +``` + +### Environment Preparation + +Always clear nature from the construction site to prevent foliage and rustle sounds from interfering with your building: + +```csharp +// 1. Clear trees, foliage, and ambient rustle triggers +TerrainClearer.ClearAroundBuilding(buildingObject, new Vector3(10f, 4f, 10f), new ClearingOptions { Padding = 2f }); + +// 2. Flatten the terrain directly beneath the foundation +buildingBuilder.FlattenTerrain(); +``` + +### NPC Pathfinding (A* Navigation) + +S1MAPI replaces standard NavMesh repairers with a robust custom A* grid system (`InteriorPathGrid`). This solves edge cases and allows NPCs to navigate cleanly inside tight building footprints: + +```csharp +NavigationBuilder navBuilder = buildingBuilder.CreateNavigationBuilder(); +navBuilder.Build(); // Always call Build() after physical walls are placed ``` ## Lighting @@ -174,15 +222,33 @@ using S1MAPI.Building.Structural; **Note:** For S1 game furniture meshes, use `InteriorBuilder` instead. -## Prefabs +## Building Part Registry + +When you call `builder.Build()`, a `BuildingPartRegistry` component is attached to the root GameObject. This registry catalogs all generated meshes automatically: + +```csharp +BuildingPartRegistry registry = builder.Registry; + +// Example: Change the material of all exterior walls at runtime +registry.SetMaterial(BuildingPart.ExteriorWalls, newMaterial); +``` + +## Prefabs & Multiplayer Sync -Place Schedule 1 game prefabs inside your building using `BuildingBuilder`: +Place Schedule 1 game prefabs inside your building using `BuildingBuilder` or the `PrefabPlacer` component: ```csharp -// Networked prefab (syncs across clients) - ATM has NetworkObject +// Fluent API .AddPrefab(Prefabs.ATM, position, rotation) ``` +Alternatively, you can use `PrefabPlacer` directly. This is crucial for networked interactables (like Doors and Light Switches) as it utilizes `NetworkedPrefabLinker` under the hood to perfectly synchronize the object's parent hierarchy to joining clients over FishNet: + +```csharp +PrefabPlacer placer = new PrefabPlacer(building.transform); +placer.Place(Prefabs.MetalGlassDoor, position, rotation, networked: true); +``` + **Note:** `BuildingBuilder.AddPrefab()` only accepts `PrefabRef` and always spawns networked. For non-networked S1 furniture meshes, use `InteriorBuilder` methods instead. ### ⚠️ Critical: Networked Parameter From 061f51519cf773f9e1420493e613c2e8483c4d52 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 14:38:28 -0400 Subject: [PATCH 41/64] docs: add missing param tags to BuildingPartRegistry and NavigationBuilder --- Building/BuildingPartRegistry.cs | 8 ++++++++ Building/NavigationBuilder.cs | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/Building/BuildingPartRegistry.cs b/Building/BuildingPartRegistry.cs index 46b32b0..95807e0 100644 --- a/Building/BuildingPartRegistry.cs +++ b/Building/BuildingPartRegistry.cs @@ -47,6 +47,7 @@ internal void Register(WallSide side, GameObject go) /// /// Get all renderers for a building part category. /// + /// The building part category to query. public Renderer[] GetRenderers(BuildingPart part) { if (!_parts.TryGetValue(part, out var list)) @@ -64,6 +65,7 @@ public Renderer[] GetRenderers(BuildingPart part) /// /// Get all renderers for a specific wall direction. /// + /// The specific wall side to query. public Renderer[] GetRenderers(WallSide side) { if (!_wallsByDirection.TryGetValue(side, out var list)) @@ -85,6 +87,7 @@ public Renderer[] GetRenderers(WallSide side) /// /// Get all GameObjects for a building part category. /// + /// The building part category to query. public GameObject[] GetParts(BuildingPart part) { if (!_parts.TryGetValue(part, out var list)) @@ -102,6 +105,7 @@ public GameObject[] GetParts(BuildingPart part) /// /// Get all GameObjects for a specific wall direction. /// + /// The specific wall side to query. public GameObject[] GetParts(WallSide side) { if (!_wallsByDirection.TryGetValue(side, out var list)) @@ -123,6 +127,8 @@ public GameObject[] GetParts(WallSide side) /// /// Set the material on all renderers for a building part category. /// + /// The building part category to modify. + /// The material to apply. public void SetMaterial(BuildingPart part, Material material) { var renderers = GetRenderers(part); @@ -133,6 +139,8 @@ public void SetMaterial(BuildingPart part, Material material) /// /// Set the material on all renderers for a specific wall direction. /// + /// The specific wall side to modify. + /// The material to apply. public void SetMaterial(WallSide side, Material material) { var renderers = GetRenderers(side); diff --git a/Building/NavigationBuilder.cs b/Building/NavigationBuilder.cs index f834b90..6b505e1 100644 --- a/Building/NavigationBuilder.cs +++ b/Building/NavigationBuilder.cs @@ -197,6 +197,7 @@ public void Rebuild() /// /// Check if an NPC is currently being managed by this building's interior navigator. /// + /// The NPC component to check. public bool IsNPCInside(Component npc) { return _navCore != null && _navCore.IsTracking(npc); @@ -206,6 +207,7 @@ public bool IsNPCInside(Component npc) /// Show or hide the interior pathfinding grid visualization. /// Green cells are walkable, red cells are blocked. /// + /// True to show the grid, false to hide it. public void VisualizePathGrid(bool show = true) { _navCore?.VisualizeGrid(show); @@ -216,6 +218,8 @@ public void VisualizePathGrid(bool show = true) /// (wall margin, interior wall, physics collider name) to the console. /// Cell coordinates are visible in the visualization quad names (Cell_X_Z). /// + /// The X coordinate on the interior grid. + /// The Z coordinate on the interior grid. public void DiagnoseCell(int gridX, int gridZ) { _navCore?.DiagnoseCell(gridX, gridZ); From 04f7427ac141098036a87fb05b6bf2dd49b76741 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 14:39:29 -0400 Subject: [PATCH 42/64] docs: update api-overview with new building systems and components --- docs/api-overview.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/api-overview.md b/docs/api-overview.md index fa2535b..087bda0 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -129,13 +129,18 @@ interior.AddPrefab(Prefabs.ATM, position, rotation, networked: true); interior.Build(); ``` -**Component Builders:** +**Component Builders & Core Systems:** - `WallBuilder` - Wall and opening generation +- `InteriorWallBuilder` - Interior wall and opening generation - `InteriorBuilder` - Interior furniture and decoration placement using S1 meshes - `FurnitureBuilder` - Procedural furniture creation (used internally by BuildingBuilder) - `LightingBuilder` - Light fixture placement -- `DecorBuilder` - Decorative elements (trim, pillars, foundations) -- `PrefabPlacer` - Prefab instantiation with networking +- `DecorBuilder` - Decorative elements (trim, pillars, foundations, stairs) +- `RoofBuilder` - Parapet and hip roof generation +- `PrefabPlacer` - Prefab instantiation with networking (integrates with `NetworkedPrefabLinker`) +- `NavigationBuilder` & `InteriorPathGrid` - Custom A* NPC Pathfinding +- `TerrainClearer` & `TerrainFlattener` - Environment preparation +- `BuildingPartRegistry` - Post-build querying of generated geometry ## GLTF API (S1MAPI.Gltf) From 4502bfde6507260fd53fcc0398a7095efb5a2eb8 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Tue, 24 Mar 2026 23:10:40 -0400 Subject: [PATCH 43/64] feat(Building): add interior/exterior dual-material support for exterior walls --- Building/BuildingBuilder.cs | 29 +++- Building/BuildingPartRegistry.cs | 62 ++++++++ Building/Config/BuildingPalette.cs | 23 ++- Building/Structural/WallAppearance.cs | 15 +- Building/Structural/WallBuilder.cs | 126 ++++++++++++---- .../Primitives/DualMaterialBoxGenerator.cs | 134 ++++++++++++++++++ Utils/Constants.cs | 12 ++ 7 files changed, 369 insertions(+), 32 deletions(-) create mode 100644 ProceduralMesh/Generators/Primitives/DualMaterialBoxGenerator.cs diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index bc652f0..a1beead 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -879,10 +879,31 @@ private void RegisterWallChildren(GameObject wallsContainer) { Transform child = wallsContainer.transform.GetChild(i); WallSide? side = ParseWallSide(child.name); - if (side.HasValue) - _registry.Register(side.Value, child.gameObject); - else - _registry.Register(BuildingPart.ExteriorWalls, child.gameObject); + + // Solid wall — the child itself is the wall segment (has a Renderer) + if (child.GetComponent() != null) + { + if (side.HasValue) + _registry.Register(side.Value, child.gameObject); + else + _registry.Register(BuildingPart.ExteriorWalls, child.gameObject); + continue; + } + + // Container (wall with door/window) — register individual wall segments, + // skipping window frames and glass so they don't get material-swapped + for (int j = 0; j < child.childCount; j++) + { + Transform segment = child.GetChild(j); + string segName = segment.name; + if (segName.StartsWith(Constants.Window.FrameNamePrefix) || segName.Contains(Constants.Window.GlassNameSubstring)) + continue; + + if (side.HasValue) + _registry.Register(side.Value, segment.gameObject); + else + _registry.Register(BuildingPart.ExteriorWalls, segment.gameObject); + } } } diff --git a/Building/BuildingPartRegistry.cs b/Building/BuildingPartRegistry.cs index 95807e0..4ca207a 100644 --- a/Building/BuildingPartRegistry.cs +++ b/Building/BuildingPartRegistry.cs @@ -148,6 +148,68 @@ public void SetMaterial(WallSide side, Material material) renderers[i].material = material; } + /// + /// Set the material on a specific submesh index across all renderers for a building part. + /// Use submesh 0 for exterior faces, submesh 1 for interior faces on dual-material walls. + /// Renderers with fewer submeshes than are skipped. + /// + /// The building part category to modify. + /// The material to apply. + /// The submesh index to target (0 = exterior, 1 = interior). + public void SetMaterial(BuildingPart part, Material material, int submeshIndex) + { + var renderers = GetRenderers(part); + SetSubmeshMaterial(renderers, material, submeshIndex); + } + + /// + /// Set the material on a specific submesh index across all renderers for a wall direction. + /// Use submesh 0 for exterior faces, submesh 1 for interior faces on dual-material walls. + /// Renderers with fewer submeshes than are skipped. + /// + /// The specific wall side to modify. + /// The material to apply. + /// The submesh index to target (0 = exterior, 1 = interior). + public void SetMaterial(WallSide side, Material material, int submeshIndex) + { + var renderers = GetRenderers(side); + SetSubmeshMaterial(renderers, material, submeshIndex); + } + + /// + /// Set the exterior face material on all exterior wall renderers (submesh 0). + /// Only affects dual-material walls created with . + /// Single-material walls are updated normally. + /// + /// The material to apply to exterior faces. + public void SetExteriorWallMaterial(Material material) + { + SetMaterial(BuildingPart.ExteriorWalls, material, 0); + } + + /// + /// Set the interior (room-facing) material on all exterior wall renderers (submesh 1). + /// Only affects dual-material walls. Renderers with a single material are skipped. + /// + /// The material to apply to interior faces. + public void SetInteriorFaceMaterial(Material material) + { + SetMaterial(BuildingPart.ExteriorWalls, material, 1); + } + + private static void SetSubmeshMaterial(Renderer[] renderers, Material material, int submeshIndex) + { + for (int i = 0; i < renderers.Length; i++) + { + Material[] mats = renderers[i].materials; + if (submeshIndex < mats.Length) + { + mats[submeshIndex] = material; + renderers[i].materials = mats; + } + } + } + #endregion } } diff --git a/Building/Config/BuildingPalette.cs b/Building/Config/BuildingPalette.cs index 29d490c..dc3536f 100644 --- a/Building/Config/BuildingPalette.cs +++ b/Building/Config/BuildingPalette.cs @@ -19,10 +19,18 @@ public sealed class BuildingPalette /// Wall material (optional, uses color if null) public Material? WallMaterial { get; set; } - + /// Wall color (used if WallMaterial is null) public Color WallColor { get; set; } = new Color(0.9f, 0.85f, 0.7f); + /// Interior wall material for the room-facing side of exterior walls. + /// When null, exterior walls use a single material on all faces. + public Material? InteriorWallMaterial { get; set; } + + /// Interior wall color (used when InteriorWallMaterial is null). + /// Defaults to WallColor when null. + public Color? InteriorWallColor { get; set; } + /// Ceiling material (optional, uses color if null) public Material? CeilingMaterial { get; set; } @@ -66,6 +74,8 @@ public sealed class BuildingPalette FloorColor = FloorColor, WallMaterial = WallMaterial, WallColor = WallColor, + InteriorWallMaterial = InteriorWallMaterial, + InteriorWallColor = InteriorWallColor, CeilingMaterial = CeilingMaterial, CeilingColor = CeilingColor, TrimMaterial = TrimMaterial, @@ -96,6 +106,17 @@ public BuildingPalette WithWalls(Material material) return this; } + /// + /// Set interior wall material and return this palette for chaining. + /// When set, exterior wall faces use and room-facing + /// faces use this material. + /// + public BuildingPalette WithInteriorWalls(Material material) + { + InteriorWallMaterial = material; + return this; + } + #endregion } } diff --git a/Building/Structural/WallAppearance.cs b/Building/Structural/WallAppearance.cs index 056336a..8516bd6 100644 --- a/Building/Structural/WallAppearance.cs +++ b/Building/Structural/WallAppearance.cs @@ -14,15 +14,28 @@ public sealed class WallAppearance /// Optional wall material override. Null uses the palette default. public Material? Material { get; } + /// Optional interior wall material override (room-facing side). + /// Null uses the palette's default. + public Material? InteriorMaterial { get; } + + /// Optional interior wall color override. + /// Null uses the palette's (or WallColor) default. + public Color? InteriorColor { get; } + /// /// Create a wall appearance override. /// /// Optional color override (null = use palette) /// Optional material override (null = use palette) - public WallAppearance(Color? color = null, Material? material = null) + /// Optional interior material override (null = use palette) + /// Optional interior color override (null = use palette) + public WallAppearance(Color? color = null, Material? material = null, + Material? interiorMaterial = null, Color? interiorColor = null) { Color = color; Material = material; + InteriorMaterial = interiorMaterial; + InteriorColor = interiorColor; } } } diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index e6ec86c..65edfa3 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using S1MAPI.Building.Config; using S1MAPI.ProceduralMesh; +using S1MAPI.ProceduralMesh.Generators.Primitives; using S1MAPI.Utils; using UnityEngine; using S1MAPI.S1; @@ -178,6 +179,11 @@ public sealed class WallBuilder private IReadOnlyDictionary? _wallOverrides; private GameObject? _wallsContainer; + // Per-wall interior material state, set in BuildWall() and read by CreateWallSegment() + private WallSide _currentSide; + private Material? _currentInteriorMaterial; + private Color _currentInteriorColor; + #endregion #region Constructor @@ -272,6 +278,10 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) Color wallColor = GetWallColor(side); Material? wallMaterial = GetWallMaterial(side); + _currentSide = side; + _currentInteriorMaterial = GetWallInteriorMaterial(side); + _currentInteriorColor = GetWallInteriorColor(side); + var (position, size, isVertical) = GetWallTransform(side); string wallName = $"{side}Wall"; @@ -327,9 +337,7 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) private GameObject CreateSolidWall(string name, Vector3 position, Vector3 size, Color wallColor, Material? wallMaterial) { - GameObject wall = PrimitiveBuilder.CreateBox(name, position, size, wallColor, _wallsContainer!.transform); - ApplyWallMaterial(wall, wallMaterial); - return wall; + return CreateWallSegment(name, position, size, wallColor, wallMaterial, _wallsContainer!.transform); } private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical, Color wallColor, Material? wallMaterial) @@ -358,8 +366,7 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w Vector3 leftSize = isVertical ? new Vector3(_wallThickness, wallHeight, leftWidth) : new Vector3(leftWidth, wallHeight, _wallThickness); - GameObject left = PrimitiveBuilder.CreateBox($"{name}_Left", wallCenter + doorShift + leftOffset, leftSize, wallColor, container.transform); - ApplyWallMaterial(left, wallMaterial); + CreateWallSegment($"{name}_Left", wallCenter + doorShift + leftOffset, leftSize, wallColor, wallMaterial, container.transform); } // Right segment @@ -370,8 +377,7 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w Vector3 rightSize = isVertical ? new Vector3(_wallThickness, wallHeight, rightWidth) : new Vector3(rightWidth, wallHeight, _wallThickness); - GameObject right = PrimitiveBuilder.CreateBox($"{name}_Right", wallCenter + doorShift + rightOffset, rightSize, wallColor, container.transform); - ApplyWallMaterial(right, wallMaterial); + CreateWallSegment($"{name}_Right", wallCenter + doorShift + rightOffset, rightSize, wallColor, wallMaterial, container.transform); } // Top segment (wall above door) @@ -383,8 +389,7 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w : new Vector3(doorWidth, topHeight, _wallThickness); float topCenterY = wallHeight / 2f - topHeight / 2f; Vector3 topOffset = Vector3.up * topCenterY; - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + doorShift + topOffset, topSize, wallColor, container.transform); - ApplyWallMaterial(top, wallMaterial); + CreateWallSegment($"{name}_Top", wallCenter + doorShift + topOffset, topSize, wallColor, wallMaterial, container.transform); } return container; @@ -429,8 +434,7 @@ private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, : new Vector3(doorWidth, topHeight, _wallThickness); float topCenterY = wallHeight / 2f - topHeight / 2f; Vector3 topOffset = Vector3.up * topCenterY; - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", shiftedCenter + topOffset, topSize, wallColor, container.transform); - ApplyWallMaterial(top, wallMaterial); + CreateWallSegment($"{name}_Top", shiftedCenter + topOffset, topSize, wallColor, wallMaterial, container.transform); } return container; @@ -453,8 +457,7 @@ private void BuildDoorSideSegment( Vector3 size = isVertical ? new Vector3(_wallThickness, wallHeight, fullSideWidth) : new Vector3(fullSideWidth, wallHeight, _wallThickness); - GameObject solid = PrimitiveBuilder.CreateBox($"{wallName}{suffix}", wallCenter + offset, size, wallColor, parent); - ApplyWallMaterial(solid, wallMaterial); + CreateWallSegment($"{wallName}{suffix}", wallCenter + offset, size, wallColor, wallMaterial, parent); return; } @@ -471,8 +474,7 @@ private void BuildDoorSideSegment( Vector3 stripSize = isVertical ? new Vector3(_wallThickness, wallHeight, stripWidth) : new Vector3(stripWidth, wallHeight, _wallThickness); - GameObject strip = PrimitiveBuilder.CreateBox($"{wallName}{suffix}", wallCenter + stripOffset, stripSize, wallColor, parent); - ApplyWallMaterial(strip, wallMaterial); + CreateWallSegment($"{wallName}{suffix}", wallCenter + stripOffset, stripSize, wallColor, wallMaterial, parent); // Window section in the remaining area (no overlap with strip so InsetDoorWallSegments works) float winSectionCenterOffset = doorWidth / 2f + stripWidth + windowSectionWidth / 2f; @@ -556,8 +558,7 @@ private void CreateWindowInSection( ? new Vector3(_wallThickness, windowBottom, sectionWidth) : new Vector3(sectionWidth, windowBottom, _wallThickness); Vector3 bottomOffset = Vector3.down * (halfHeight - windowBottom / 2f); - GameObject bottom = PrimitiveBuilder.CreateBox($"{namePrefix}_Bottom", sectionCenter + bottomOffset, bottomSize, wallColor, parent); - ApplyWallMaterial(bottom, wallMaterial); + CreateWallSegment($"{namePrefix}_Bottom", sectionCenter + bottomOffset, bottomSize, wallColor, wallMaterial, parent); } // Top segment (header) — full section width, no shift @@ -567,8 +568,7 @@ private void CreateWindowInSection( ? new Vector3(_wallThickness, topHeight, sectionWidth) : new Vector3(sectionWidth, topHeight, _wallThickness); Vector3 topOffset = Vector3.up * (halfHeight - topHeight / 2f); - GameObject top = PrimitiveBuilder.CreateBox($"{namePrefix}_Top", sectionCenter + topOffset, topSize, wallColor, parent); - ApplyWallMaterial(top, wallMaterial); + CreateWallSegment($"{namePrefix}_Top", sectionCenter + topOffset, topSize, wallColor, wallMaterial, parent); } // Side segments — asymmetric widths when window is offset @@ -587,8 +587,7 @@ private void CreateWindowInSection( Vector3 leftOffset = isVertical ? new Vector3(0f, windowCenterY, windowWidth / 2f + leftSideWidth / 2f) : new Vector3(-(windowWidth / 2f + leftSideWidth / 2f), windowCenterY, 0f); - GameObject leftSide = PrimitiveBuilder.CreateBox($"{namePrefix}_Left", windowCenter + leftOffset, leftSize, wallColor, parent); - ApplyWallMaterial(leftSide, wallMaterial); + CreateWallSegment($"{namePrefix}_Left", windowCenter + leftOffset, leftSize, wallColor, wallMaterial, parent); } if (rightSideWidth > Constants.Window.SegmentThreshold) @@ -599,8 +598,7 @@ private void CreateWindowInSection( Vector3 rightOffset = isVertical ? new Vector3(0f, windowCenterY, -(windowWidth / 2f + rightSideWidth / 2f)) : new Vector3(windowWidth / 2f + rightSideWidth / 2f, windowCenterY, 0f); - GameObject rightSide = PrimitiveBuilder.CreateBox($"{namePrefix}_Right", windowCenter + rightOffset, rightSize, wallColor, parent); - ApplyWallMaterial(rightSide, wallMaterial); + CreateWallSegment($"{namePrefix}_Right", windowCenter + rightOffset, rightSize, wallColor, wallMaterial, parent); } // Multi-pane window rendering @@ -655,11 +653,10 @@ private void CreateWindowInSection( ? new Vector3(_wallThickness, windowHeight, dividerW) : new Vector3(dividerW, windowHeight, _wallThickness); - GameObject divider = PrimitiveBuilder.CreateBox( + CreateWallSegment( $"{namePrefix}_Divider_{i}", windowCenter + dividerShift + new Vector3(0f, windowCenterY, 0f), - dividerSize, wallColor, parent); - ApplyWallMaterial(divider, wallMaterial); + dividerSize, wallColor, wallMaterial, parent); } } } @@ -736,6 +733,83 @@ private void ApplyFrameMaterial(GameObject frame, Material material) if (r != null) r.material = material; } + private Material? GetWallInteriorMaterial(WallSide side) + { + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.InteriorMaterial != null) + { + return appearance.InteriorMaterial; + } + return _palette.InteriorWallMaterial; + } + + private Color GetWallInteriorColor(WallSide side) + { + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.InteriorColor.HasValue) + { + return appearance.InteriorColor.Value; + } + return _palette.InteriorWallColor ?? _palette.WallColor; + } + + /// + /// Unified wall segment creation. When an interior material is active, + /// creates a dual-material mesh; otherwise delegates to the standard + /// path. + /// + private GameObject CreateWallSegment(string name, Vector3 position, Vector3 size, + Color wallColor, Material? wallMaterial, Transform parent) + { + if (_currentInteriorMaterial != null) + return CreateDualMaterialBox(name, position, size, wallColor, wallMaterial, parent); + + GameObject wall = PrimitiveBuilder.CreateBox(name, position, size, wallColor, parent); + ApplyWallMaterial(wall, wallMaterial); + return wall; + } + + /// + /// Creates a wall segment with a dual-submesh mesh so the interior (room-facing) face + /// can use a different material from the exterior and edge faces. + /// + private GameObject CreateDualMaterialBox(string name, Vector3 position, Vector3 size, + Color exteriorColor, Material? exteriorMaterial, Transform parent) + { + var interiorFace = _currentSide switch + { + WallSide.North => DualMaterialBoxGenerator.InteriorFace.NegZ, + WallSide.South => DualMaterialBoxGenerator.InteriorFace.PosZ, + WallSide.East => DualMaterialBoxGenerator.InteriorFace.NegX, + WallSide.West => DualMaterialBoxGenerator.InteriorFace.PosX, + _ => DualMaterialBoxGenerator.InteriorFace.NegZ + }; + + Mesh mesh = DualMaterialBoxGenerator.Generate(interiorFace); + + GameObject go = new GameObject(name); + go.transform.SetParent(parent); + go.transform.localPosition = position; + go.transform.localScale = size; + + MeshFilter mf = go.AddComponent(); + mf.sharedMesh = mesh; + + MeshRenderer mr = go.AddComponent(); + mr.allowOcclusionWhenDynamic = false; + + Material extMat = exteriorMaterial ?? MaterialPresets.Opaque(exteriorColor); + Material intMat = _currentInteriorMaterial ?? MaterialPresets.Opaque(_currentInteriorColor); + + mr.materials = new Material[] { extMat, intMat }; + + go.AddComponent(); + + return go; + } + #endregion } } diff --git a/ProceduralMesh/Generators/Primitives/DualMaterialBoxGenerator.cs b/ProceduralMesh/Generators/Primitives/DualMaterialBoxGenerator.cs new file mode 100644 index 0000000..195ba49 --- /dev/null +++ b/ProceduralMesh/Generators/Primitives/DualMaterialBoxGenerator.cs @@ -0,0 +1,134 @@ +using UnityEngine; + +namespace S1MAPI.ProceduralMesh.Generators.Primitives +{ + /// + /// Generates a unit-cube mesh (-0.5 to 0.5) with two submeshes: one for the + /// interior face and one for all other faces (exterior + edges). + /// Used by WallBuilder when an interior material is specified so that + /// interior and exterior wall faces can have different materials. + /// + internal static class DualMaterialBoxGenerator + { + /// Which face of the unit cube is the interior (room-facing) side. + internal enum InteriorFace + { + /// -Z face (north wall interior). + NegZ, + /// +Z face (south wall interior). + PosZ, + /// -X face (east wall interior). + NegX, + /// +X face (west wall interior). + PosX + } + + private static readonly Mesh?[] Cache = new Mesh?[4]; + + /// + /// Get a cached unit-cube mesh with 2 submeshes for the given interior face. + /// Submesh 0 = exterior (5 faces), submesh 1 = interior (1 face). + /// + internal static Mesh Generate(InteriorFace interiorFace) + { + int index = (int)interiorFace; + Mesh? cached = Cache[index]; + if (cached != null) + return cached; + + Mesh mesh = BuildMesh(interiorFace); + Cache[index] = mesh; + return mesh; + } + + private static Mesh BuildMesh(InteriorFace interiorFace) + { + // 6 faces × 4 vertices = 24 vertices with per-face normals and UVs + Vector3[] vertices = new Vector3[24]; + Vector3[] normals = new Vector3[24]; + Vector2[] uvs = new Vector2[24]; + + // Face definitions: (normal, tangent axis 1, tangent axis 2, center offset) + // Each face has 4 verts at corners: center ± t1*0.5 ± t2*0.5 + var faces = new[] + { + (normal: Vector3.right, t1: Vector3.forward, t2: Vector3.up, offset: Vector3.right * 0.5f), // +X + (normal: Vector3.left, t1: Vector3.back, t2: Vector3.up, offset: Vector3.left * 0.5f), // -X + (normal: Vector3.up, t1: Vector3.right, t2: Vector3.forward, offset: Vector3.up * 0.5f), // +Y + (normal: Vector3.down, t1: Vector3.right, t2: Vector3.back, offset: Vector3.down * 0.5f), // -Y + (normal: Vector3.forward, t1: Vector3.left, t2: Vector3.up, offset: Vector3.forward * 0.5f), // +Z + (normal: Vector3.back, t1: Vector3.right, t2: Vector3.up, offset: Vector3.back * 0.5f) // -Z + }; + + // Map InteriorFace enum to face index + int interiorFaceIndex = interiorFace switch + { + InteriorFace.PosX => 0, // +X + InteriorFace.NegX => 1, // -X + InteriorFace.PosZ => 4, // +Z + InteriorFace.NegZ => 5, // -Z + _ => 5 + }; + + int[] exteriorTris = new int[30]; // 5 faces × 6 indices + int[] interiorTris = new int[6]; // 1 face × 6 indices + int ei = 0, ii = 0; + + for (int f = 0; f < 6; f++) + { + int vi = f * 4; + var (normal, t1, t2, offset) = faces[f]; + + vertices[vi + 0] = offset - t1 * 0.5f - t2 * 0.5f; + vertices[vi + 1] = offset + t1 * 0.5f - t2 * 0.5f; + vertices[vi + 2] = offset + t1 * 0.5f + t2 * 0.5f; + vertices[vi + 3] = offset - t1 * 0.5f + t2 * 0.5f; + + normals[vi + 0] = normal; + normals[vi + 1] = normal; + normals[vi + 2] = normal; + normals[vi + 3] = normal; + + uvs[vi + 0] = new Vector2(0f, 0f); + uvs[vi + 1] = new Vector2(1f, 0f); + uvs[vi + 2] = new Vector2(1f, 1f); + uvs[vi + 3] = new Vector2(0f, 1f); + + if (f == interiorFaceIndex) + { + interiorTris[ii++] = vi + 0; + interiorTris[ii++] = vi + 2; + interiorTris[ii++] = vi + 1; + interiorTris[ii++] = vi + 0; + interiorTris[ii++] = vi + 3; + interiorTris[ii++] = vi + 2; + } + else + { + exteriorTris[ei++] = vi + 0; + exteriorTris[ei++] = vi + 2; + exteriorTris[ei++] = vi + 1; + exteriorTris[ei++] = vi + 0; + exteriorTris[ei++] = vi + 3; + exteriorTris[ei++] = vi + 2; + } + } + + Mesh mesh = new Mesh + { + name = $"DualMaterialBox_{interiorFace}", + vertices = vertices, + normals = normals, + uv = uvs, + subMeshCount = 2 + }; + + mesh.SetTriangles(exteriorTris, 0); + mesh.SetTriangles(interiorTris, 1); + mesh.RecalculateBounds(); + mesh.RecalculateTangents(); + + return mesh; + } + } +} diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 64faeb0..e0189bd 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -231,6 +231,18 @@ public static class Window /// Margin subtracted from the available side width when sizing the door strip. /// public const float DoorStripMargin = 0.4f; + + /// + /// Name prefix used for window frame GameObjects. + /// Used by registration to exclude frames from wall material swaps. + /// + public const string FrameNamePrefix = "Frame"; + + /// + /// Substring present in window glass GameObject names. + /// Used by registration to exclude glass from wall material swaps. + /// + public const string GlassNameSubstring = "Glass"; } /// From c4b9eb325b614171f933c235907c3d484502105e Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Fri, 27 Mar 2026 07:49:51 -0400 Subject: [PATCH 44/64] feat(Building): stair doorway lerp, directed NPC routing, and interior nav hardening --- Building/InteriorNavigatorCore.cs | 313 ++++++++++++++++++++++++++---- Building/NavigationBuilder.cs | 34 ++++ Utils/Constants.cs | 24 +++ 3 files changed, 332 insertions(+), 39 deletions(-) diff --git a/Building/InteriorNavigatorCore.cs b/Building/InteriorNavigatorCore.cs index a3d758d..453d57a 100644 --- a/Building/InteriorNavigatorCore.cs +++ b/Building/InteriorNavigatorCore.cs @@ -39,7 +39,8 @@ private sealed class TrackedNPC public Component NpcComponent; public NPCNavState State; public NavDoorwayInfo TargetDoorway; - public Vector3 DoorwayExteriorWorld; + public Vector3 DoorwayExteriorWorld; // stair base (ground level) — NavMeshAgent target + public Vector3 DoorwayThresholdWorld; // just outside doorway at floor level — lerp phase 1 target public Vector3 DoorwayInteriorWorld; public List? Path; public int PathIndex; @@ -52,6 +53,7 @@ private sealed class TrackedNPC public float LerpDuration; public Vector3 LerpStart; public Vector3 LerpEnd; + public bool Arrived; // directed mode: true once OnArrival has fired public Vector3? PendingExteriorDestination; // set when NPC exits to resume navigation public Vector3? SavedChasePosition; // chase target position saved before exit public float RepathTimer; // fallback re-pathfind timer for all NPCs @@ -59,6 +61,7 @@ private sealed class TrackedNPC public Vector3 StuckStartPos; // position when stuck timer began public Vector3 LastChaseTargetLocal; // last chase target used for repath (avoids redundant recompute) public float ApproachStartTime; // Time.time when Approaching state began + public bool IsStairLerp; // true during stair climb/descent lerp (Y follows ramp slope) // Cached reflection results public object? MovementRef; // NPCMovement instance @@ -132,6 +135,12 @@ public void SetValue(object target, object? value) private static MethodInfo? _originalSetDestination; private static Harmony? _harmony; + // Chase detection: NPCMovement.npc → NPC.Behaviour → activeBehaviour, check if CombatBehaviour + private static MemberAccessor _movementNpcAccessor; // NPCMovement.npc (protected field) + private static MemberAccessor _npcBehaviourAccessor; // NPC.Behaviour + private static MemberAccessor _activeBehaviourAccessor; // NPCBehaviour.activeBehaviour + private static Type? _combatBehaviourType; + #endregion #region Constructor @@ -193,6 +202,33 @@ private static void ResolveReflection() _speedScaleAccessor = new MemberAccessor(_npcMovementType, "MovementSpeedScale", pub); _moveSpeedMultAccessor = new MemberAccessor(_npcMovementType, "MoveSpeedMultiplier", pub); _hasDestinationAccessor = new MemberAccessor(_npcMovementType, "HasDestination", pub); + + // Chase detection: NPCMovement.npc → NPC.Behaviour → activeBehaviour → CombatBehaviour + const BindingFlags nonPub = BindingFlags.NonPublic | BindingFlags.Instance; + _movementNpcAccessor = new MemberAccessor(_npcMovementType, "npc", nonPub | BindingFlags.Public); +#if IL2CPP + const string npcTypeName = "Il2CppScheduleOne.NPCs.NPC"; + const string behaviourTypeName = "Il2CppScheduleOne.NPCs.Behaviour.NPCBehaviour"; + const string combatTypeName = "Il2CppScheduleOne.Combat.CombatBehaviour"; +#else + const string npcTypeName = "ScheduleOne.NPCs.NPC"; + const string behaviourTypeName = "ScheduleOne.NPCs.Behaviour.NPCBehaviour"; + const string combatTypeName = "ScheduleOne.Combat.CombatBehaviour"; +#endif + Type? npcType = null; + Type? npcBehaviourType = null; + foreach (Assembly asm2 in AppDomain.CurrentDomain.GetAssemblies()) + { + npcType ??= asm2.GetType(npcTypeName); + npcBehaviourType ??= asm2.GetType(behaviourTypeName); + _combatBehaviourType ??= asm2.GetType(combatTypeName); + if (npcType != null && npcBehaviourType != null && _combatBehaviourType != null) + break; + } + if (npcType != null) + _npcBehaviourAccessor = new MemberAccessor(npcType, "Behaviour", pub); + if (npcBehaviourType != null) + _activeBehaviourAccessor = new MemberAccessor(npcBehaviourType, "activeBehaviour", pub); } private static void ApplyPatchIfNeeded() @@ -278,11 +314,15 @@ private static bool SetDestinationPrefix(object __instance, Vector3 pos) if (nav == null || nav._buildingRoot == null) continue; Vector3 localPos = nav._buildingRoot.InverseTransformPoint(pos); - if (!nav.IsInsideBuilding(localPos, margin: 0.5f)) + if (!nav.IsInsideBuilding(localPos)) { - // Extended: combat AI resolves targets inside the building to NavMesh - // points at the carving boundary (~0.8m outside). Catch when player is inside. - if (!nav.IsInsideBuilding(localPos, margin: 3f) || !nav.IsPlayerInside()) + // Extended: combat AI resolves targets to carving boundary points + // (~0.8m outside). Only intercept if the NPC is in combat — prevents + // false interception of consumer NPCs walking to exterior positions + // near the building (e.g. stair approach points). + if (!IsNPCInCombat(movement) || + !nav.IsInsideBuilding(localPos, margin: 3f) || + !nav.IsPlayerInside()) continue; } @@ -290,8 +330,11 @@ private static bool SetDestinationPrefix(object __instance, Vector3 pos) localPos.x = Mathf.Clamp(localPos.x, 0f, nav._roomSize.x); localPos.z = Mathf.Clamp(localPos.z, 0f, nav._roomSize.z); - // Detect chase: if destination is near the player, set up continuous tracking - Transform? chaseTarget = DetectChaseTarget(pos); + // Chase detection: only enable continuous player tracking when the + // NPC's active behaviour is combat (PursuitBehaviour, CombatBehaviour, etc.). + // This avoids false positives from consumer mods sending NPCs to positions + // near the player (e.g. consumer NPCs walking to interior positions). + Transform? chaseTarget = IsNPCInCombat(movement) ? DetectChaseTarget(pos) : null; // Already tracked by this building — update target, always block original if (nav._tracked.TryGetValue(movement, out TrackedNPC? existing)) @@ -303,6 +346,10 @@ private static bool SetDestinationPrefix(object __instance, Vector3 pos) existing.ChaseTarget = chaseTarget; existing.LastChaseTargetLocal = localPos; } + else + { + existing.ChaseTarget = null; + } if (existing.State == NPCNavState.Inside) nav.ComputePathToTarget(existing); @@ -398,6 +445,36 @@ private static bool UpdateDestinationPrefix(object __instance) return true; } + /// + /// Check if the NPC's active behaviour is a CombatBehaviour (or subclass like PursuitBehaviour). + /// Uses reflection to read NPCMovement.npc → NPC.Behaviour.activeBehaviour without + /// compile-time ScheduleOne dependencies. Returns false if reflection fails (graceful fallback: no chase). + /// + private static bool IsNPCInCombat(Component movement) + { + if (_combatBehaviourType == null || !_movementNpcAccessor.IsValid || + !_npcBehaviourAccessor.IsValid || !_activeBehaviourAccessor.IsValid) + return false; + + try + { + object? npc = _movementNpcAccessor.GetValue(movement); + if (npc == null) return false; + + object? behaviourComp = _npcBehaviourAccessor.GetValue(npc); + if (behaviourComp == null) return false; + + object? activeBehaviour = _activeBehaviourAccessor.GetValue(behaviourComp); + if (activeBehaviour == null) return false; + + return _combatBehaviourType.IsInstanceOfType(activeBehaviour); + } + catch + { + return false; + } + } + /// /// Check if the destination is near any player (indicating a chase scenario). /// Returns the nearest player's Transform if so, null otherwise. @@ -435,6 +512,7 @@ public void SendNPCToPosition(Component npc, Vector3 localTarget, Action? onArri { existing.TargetLocal = localTarget; existing.OnArrival = onArrival; + existing.Arrived = false; existing.ChaseTarget = null; if (existing.State == NPCNavState.Inside) ComputePathToTarget(existing); @@ -452,11 +530,51 @@ public void SendNPCToPosition(Component npc, Vector3 localTarget, Action? onArri tracked.OnArrival = onArrival; tracked.ChaseTarget = null; - BeginApproach(tracked); - SendAgentToDoorway(tracked); + // Check if NPC is already near a doorway — skip approach if close enough. + // For stair doorways, check proximity to the stair base (not door center) + // so the NPC doesn't skip the stair climb from the sidewalk. + Vector3 npcPos = npc.transform.position; + NavDoorwayInfo? nearDoor = null; + for (int i = 0; i < _doorways.Count; i++) + { + NavDoorwayInfo door = _doorways[i]; + if (door.IsInterior) continue; + + Vector3 checkPoint; + float checkThreshold; + if (door.StairBasePosition.HasValue) + { + checkPoint = _buildingRoot.TransformPoint(door.StairBasePosition.Value); + checkThreshold = Constants.InteriorNav.StairApproachThreshold; + } + else + { + checkPoint = _buildingRoot.TransformPoint(door.Center); + checkThreshold = Constants.InteriorNav.DoorwayApproachThreshold; + } + + if (HorizontalDistance(npcPos, checkPoint) < checkThreshold) + { + nearDoor = door; + break; + } + } + _tracked[npc] = tracked; _globallyManaged.Add(npc); + if (nearDoor != null) + { + tracked.TargetDoorway = nearDoor; + ComputeDoorwayPoints(tracked, nearDoor); + BeginDoorwayEntry(npc, tracked); + } + else + { + BeginApproach(tracked); + SendAgentToDoorway(tracked); + } + DebugLog.Info($"[InteriorNavigator] Sending NPC to local ({localTarget.x:F1}, {localTarget.z:F1})"); } @@ -615,9 +733,10 @@ public void Update() // Check if we still need phase 2 (through doorway) float distToInterior = Vector3.Distance( npc.transform.position, data.DoorwayInteriorWorld); - if (distToInterior > 0.5f) + if (distToInterior > Constants.InteriorNav.PhaseTransitionThreshold) { // Phase 1 complete — now lerp through the doorway + data.IsStairLerp = false; data.Speed = GetNPCSpeed(data); data.LerpStart = npc.transform.position; data.LerpEnd = data.DoorwayInteriorWorld; @@ -629,7 +748,6 @@ public void Update() else { data.State = NPCNavState.Inside; - DebugLog.Info("[InteriorNavigator] NPC entered building, computing A* path..."); ComputePathToTarget(data); } }); @@ -638,12 +756,50 @@ public void Update() UpdateInside(npc, data); break; case NPCNavState.Exiting: + { + // Stuck detection for exit — force doorway leave if NPC + // hasn't made progress in 3 seconds + Vector3 exitPos = npc.transform.position; + if (data.StuckTimer == 0f) + data.StuckStartPos = exitPos; + data.StuckTimer += Time.deltaTime; + float exitDisplSq = (exitPos.x - data.StuckStartPos.x) * (exitPos.x - data.StuckStartPos.x) + + (exitPos.z - data.StuckStartPos.z) * (exitPos.z - data.StuckStartPos.z); + if (exitDisplSq > Constants.InteriorNav.ExitStuckDisplacementSq) + data.StuckTimer = 0f; + else if (data.StuckTimer > Constants.InteriorNav.ExitStuckTimeout) + { + DebugLog.Warning("[InteriorNavigator] NPC stuck during exit, forcing doorway leave."); + BeginDoorwayLeave(npc, data); + break; + } + UpdatePathFollow(npc, data, onComplete: () => BeginDoorwayLeave(npc, data)); break; + } case NPCNavState.LeavingDoorway: UpdateLerp(npc, data, onComplete: () => - ReleaseNPC(npc, data, warpToExterior: false)); + { + // If not yet at stair base, descend stairs + float distToExt = Vector3.Distance( + npc.transform.position, data.DoorwayExteriorWorld); + if (distToExt > Constants.InteriorNav.PhaseTransitionThreshold) + { + data.IsStairLerp = data.TargetDoorway.StairBasePosition.HasValue; + data.Speed = GetNPCSpeed(data); + data.LerpStart = npc.transform.position; + data.LerpEnd = data.DoorwayExteriorWorld; + float dist = Vector3.Distance(data.LerpStart, data.LerpEnd); + data.LerpDuration = Mathf.Max( + dist / Mathf.Max(data.Speed, 1f), 0.1f); + data.LerpProgress = 0f; + } + else + { + ReleaseNPC(npc, data, warpToExterior: false); + } + }); break; } } @@ -740,7 +896,11 @@ private void BeginApproach(TrackedNPC data) private void UpdateApproaching(Component npc, TrackedNPC data) { Vector3 npcPos = npc.transform.position; - float threshold = Constants.InteriorNav.DoorwayApproachThreshold; + // Stair doorways: use a tight threshold so the NPC walks all the way + // to the stair base before we take over and lerp up the stairs. + float threshold = data.TargetDoorway.StairBasePosition.HasValue + ? Constants.InteriorNav.StairApproachThreshold + : Constants.InteriorNav.DoorwayApproachThreshold; // Check distance to target doorway float distXZ = HorizontalDistance(npcPos, data.DoorwayExteriorWorld); @@ -800,7 +960,9 @@ private void UpdateApproaching(Component npc, TrackedNPC data) bool remainingValid = !float.IsInfinity(remaining) && !float.IsNaN(remaining); // Agent finished its path near-ish to doorway — enter - if (!pathPending && remainingValid && remaining < 0.5f && distXZ < 8f) + float maxEntryDist = data.TargetDoorway.StairBasePosition.HasValue + ? Constants.InteriorNav.StairMaxEntryDistance : 8f; + if (!pathPending && remainingValid && remaining < 0.5f && distXZ < maxEntryDist) { DebugLog.Info($"[InteriorNavigator] Agent path done near doorway (distXZ={distXZ:F2}), entering..."); BeginDoorwayEntry(npc, data); @@ -855,13 +1017,29 @@ private void BeginDoorwayEntry(Component npc, TrackedNPC data) data.Speed = GetNPCSpeed(data); data.LerpStart = npc.transform.position; - // 2-phase entry: if NPC is far from the doorway exterior, first walk to the - // exterior point (phase 1), then through the doorway (phase 2). This prevents - // wall clipping when entry triggers from an angle. - float distToExterior = Vector3.Distance(npc.transform.position, data.DoorwayExteriorWorld); - data.LerpEnd = distToExterior > 1.0f - ? data.DoorwayExteriorWorld // Phase 1: walk to exterior - : data.DoorwayInteriorWorld; // Already near exterior, go straight through + // Entry phasing depends on whether doorway has stairs: + // Stair doorways: Phase 1 climbs to DoorwayThresholdWorld (ramp top), + // Phase 2 enters through doorway to DoorwayInteriorWorld. + // Non-stair doorways: Phase 1 walks to DoorwayExteriorWorld (corrects approach angle), + // Phase 2 enters through doorway to DoorwayInteriorWorld. + bool hasStairs = data.TargetDoorway.StairBasePosition.HasValue; + if (hasStairs) + { + float distToThreshold = Vector3.Distance(npc.transform.position, data.DoorwayThresholdWorld); + bool climbPhase = distToThreshold > Constants.InteriorNav.PhaseTransitionThreshold; + data.LerpEnd = climbPhase + ? data.DoorwayThresholdWorld + : data.DoorwayInteriorWorld; + data.IsStairLerp = climbPhase; + } + else + { + float distToExterior = Vector3.Distance(npc.transform.position, data.DoorwayExteriorWorld); + data.LerpEnd = distToExterior > Constants.InteriorNav.ExteriorAngleCorrectionThreshold + ? data.DoorwayExteriorWorld + : data.DoorwayInteriorWorld; + data.IsStairLerp = false; + } float distance = Vector3.Distance(data.LerpStart, data.LerpEnd); data.LerpDuration = Mathf.Max(distance / Mathf.Max(data.Speed, 1f), 0.1f); @@ -873,10 +1051,15 @@ private void BeginDoorwayLeave(Component npc, TrackedNPC data) { data.Speed = GetNPCSpeed(data); data.LerpStart = npc.transform.position; - data.LerpEnd = data.DoorwayExteriorWorld; + // Stair doorways: lerp to threshold first, then LeavingDoorway completion + // descends to stair base. Non-stair: go directly to exterior (single phase). + data.LerpEnd = data.TargetDoorway.StairBasePosition.HasValue + ? data.DoorwayThresholdWorld + : data.DoorwayExteriorWorld; float distance = Vector3.Distance(data.LerpStart, data.LerpEnd); data.LerpDuration = Mathf.Max(distance / Mathf.Max(data.Speed, 1f), 0.1f); data.LerpProgress = 0f; + data.IsStairLerp = false; // interior→threshold/exterior is through doorway, not on stairs data.State = NPCNavState.LeavingDoorway; } @@ -885,7 +1068,26 @@ private void UpdateLerp(Component npc, TrackedNPC data, Action onComplete) data.LerpProgress += Time.deltaTime / data.LerpDuration; float t = Mathf.Clamp01(data.LerpProgress); float smooth = t * t * (3f - 2f * t); // smoothstep - npc.transform.position = Vector3.Lerp(data.LerpStart, data.LerpEnd, smooth); + Vector3 pos = Vector3.Lerp(data.LerpStart, data.LerpEnd, smooth); + + // During stair climb/descent, project Y linearly based on horizontal + // progress from LerpStart to LerpEnd. This avoids the smoothstep arc + // that causes floating, and handles street-to-stair elevation differences + // since LerpStart.y is the NPC's actual Y (may be at street level). + if (data.IsStairLerp) + { + float dx = data.LerpEnd.x - data.LerpStart.x; + float dz = data.LerpEnd.z - data.LerpStart.z; + float lenSq = dx * dx + dz * dz; + if (lenSq > 0.001f) + { + float dot = (pos.x - data.LerpStart.x) * dx + (pos.z - data.LerpStart.z) * dz; + float rampT = Mathf.Clamp01(dot / lenSq); + pos.y = Mathf.Lerp(data.LerpStart.y, data.LerpEnd.y, rampT); + } + } + + npc.transform.position = pos; // Face movement direction RotateToward(npc.transform, data.LerpEnd - data.LerpStart); @@ -958,15 +1160,20 @@ private void UpdateInside(Component npc, TrackedNPC data) // Fallback: re-pathfind periodically even without chase target. // This handles the case where the game stops calling SetDestination // after we cleared HasDestination (NPC would otherwise stand forever). - data.RepathTimer -= Time.deltaTime; - if (data.RepathTimer <= 0f) + // Skip when Arrived — the NPC reached its destination and should idle + // until the consumer sets a new target or recalls. + if (!data.Arrived) { - data.RepathTimer = 0.5f; - if (data.Path == null || data.PathIndex >= data.Path.Count) + data.RepathTimer -= Time.deltaTime; + if (data.RepathTimer <= 0f) { - ComputePathToTarget(data); - if (data.Path != null) - DebugLog.Info($"[InteriorNavigator] Fallback re-path found {data.Path.Count} waypoints, speed={data.Speed:F1}"); + data.RepathTimer = 0.5f; + if (data.Path == null || data.PathIndex >= data.Path.Count) + { + ComputePathToTarget(data); + if (data.Path != null) + DebugLog.Info($"[InteriorNavigator] Fallback re-path found {data.Path.Count} waypoints, speed={data.Speed:F1}"); + } } } @@ -1004,6 +1211,7 @@ private void UpdateInside(Component npc, TrackedNPC data) if (data.ChaseTarget == null) { // Directed mode: arrived at destination + data.Arrived = true; data.OnArrival?.Invoke(); data.OnArrival = null; // NPC stays until RecallNPC @@ -1031,11 +1239,23 @@ private void BeginExit(Component npc, TrackedNPC data) catch { /* destroyed */ } } + data.ChaseTarget = null; + // Pathfind to doorway interior point data.Path = _grid.FindPath(currentLocal, _buildingRoot.InverseTransformPoint(data.DoorwayInteriorWorld)); data.PathIndex = 0; + data.StuckTimer = 0f; + + if (data.Path == null) + { + // No path to doorway — skip directly to leave lerp rather than + // getting stuck forever. NPC will lerp through walls if needed. + DebugLog.Warning("[InteriorNavigator] Exit pathfind failed, forcing doorway leave."); + BeginDoorwayLeave(npc, data); + return; + } + data.State = NPCNavState.Exiting; - data.ChaseTarget = null; } #endregion @@ -1193,14 +1413,21 @@ private NavDoorwayInfo FindNearestExteriorDoorway(Vector3 localPos) private void ComputeDoorwayPoints(TrackedNPC data, NavDoorwayInfo door) { - // Exterior point: well outside the carving zone on surviving NavMesh. - // Must be far enough that the NavMesh agent can path to it without - // routing along the carving boundary (which causes corner-sticking). - float extOffset = door.WallThickness / 2f + 2.5f; - Vector3 extLocal = door.Center - door.InwardNormal * extOffset; - extLocal.y = door.StairBasePosition.HasValue - ? door.StairBasePosition.Value.y - : -_foundationHeight; + // Exterior point: where the NavMesh agent walks to before we take control. + Vector3 extLocal; + if (door.StairBasePosition.HasValue) + { + // Stair doorway: use actual stair base so the NPC walks to the + // bottom of the stairs and the lerp climbs the stair surface. + extLocal = door.StairBasePosition.Value; + } + else + { + // Non-stair: well outside the carving zone on surviving NavMesh. + float extOffset = door.WallThickness / 2f + 2.5f; + extLocal = door.Center - door.InwardNormal * extOffset; + extLocal.y = -_foundationHeight; + } Vector3 extWorld = _buildingRoot.TransformPoint(extLocal); @@ -1208,12 +1435,20 @@ private void ComputeDoorwayPoints(TrackedNPC data, NavDoorwayInfo door) // corners, causing the agent to route along the carving boundary and get stuck. // The agent's SetDestination internally snaps to the nearest reachable NavMesh. + // Threshold point: just outside the doorway at floor level. + // For stair doorways this is the ramp top — Phase 1 climbs from the + // stair base to here, Phase 2 passes through the doorway. + float threshOffset = door.WallThickness / 2f + 0.3f; + Vector3 threshLocal = door.Center - door.InwardNormal * threshOffset; + threshLocal.y = door.StairBasePosition.HasValue ? 0f : extLocal.y; + // Interior point: inside the wall on the floor float intOffset = door.WallThickness / 2f + 0.3f; Vector3 intLocal = door.Center + door.InwardNormal * intOffset; intLocal.y = 0f; data.DoorwayExteriorWorld = extWorld; + data.DoorwayThresholdWorld = _buildingRoot.TransformPoint(threshLocal); data.DoorwayInteriorWorld = _buildingRoot.TransformPoint(intLocal); } diff --git a/Building/NavigationBuilder.cs b/Building/NavigationBuilder.cs index 6b505e1..2a6f04c 100644 --- a/Building/NavigationBuilder.cs +++ b/Building/NavigationBuilder.cs @@ -203,6 +203,40 @@ public bool IsNPCInside(Component npc) return _navCore != null && _navCore.IsTracking(npc); } + /// + /// Send an NPC to a position inside the building. The NPC walks to the nearest + /// doorway on exterior NavMesh, enters via lerp, then follows A* path to target. + /// + /// The NPC's NPCMovement component. + /// Target position in building-local coordinates (0,0 = building corner). + /// Optional callback invoked when the NPC reaches the destination. + public void SendNPCToPosition(Component npc, Vector3 localTarget, Action? onArrival = null) + => _navCore?.SendNPCToPosition(npc, localTarget, onArrival); + + /// + /// Recall an NPC from the building. If inside, begins exit via A* path to doorway. + /// If still approaching, releases immediately. The NPC's NavMeshAgent is re-enabled on exit. + /// + /// The NPC's NPCMovement component. + public void RecallNPC(Component npc) + => _navCore?.RecallNPC(npc); + + /// + /// Convert a world-space position to building-local coordinates. + /// Use this to convert world positions (e.g. furniture transforms) to the local + /// coordinates expected by . + /// + /// The world-space position to convert. + public Vector3 WorldToLocal(Vector3 worldPosition) + => _buildingRoot.InverseTransformPoint(worldPosition); + + /// + /// Convert a building-local position to world-space coordinates. + /// + /// The building-local position to convert. + public Vector3 LocalToWorld(Vector3 localPosition) + => _buildingRoot.TransformPoint(localPosition); + /// /// Show or hide the interior pathfinding grid visualization. /// Green cells are walkable, red cells are blocked. diff --git a/Utils/Constants.cs b/Utils/Constants.cs index e0189bd..b7228ff 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -365,6 +365,30 @@ public static class InteriorNav /// stops short of the exact exterior point due to carving boundary erosion. public const float DoorwayApproachThreshold = 4.0f; + /// Approach threshold for stair doorways. Tighter than + /// so the NPC walks all the way + /// to the stair base before the lerp takes over. + public const float StairApproachThreshold = 1.5f; + + /// Distance threshold for phase transitions during doorway entry/exit. + /// If the NPC is closer than this to the next phase target, skip to the following phase. + public const float PhaseTransitionThreshold = 0.15f; + + /// Max horizontal distance from doorway for agent-done entry on stair doorways. + /// Tighter than non-stair (8m) to avoid triggering entry from the sidewalk. + public const float StairMaxEntryDistance = 3f; + + /// Distance threshold for non-stair Phase 1 (angle correction). + /// If the NPC is farther than this from the exterior point, Phase 1 walks + /// to the exterior first to correct approach angle before entering. + public const float ExteriorAngleCorrectionThreshold = 1.0f; + + /// Time in seconds before an NPC in the Exiting state is considered stuck. + public const float ExitStuckTimeout = 3.0f; + + /// Squared displacement below which the stuck timer accumulates during exit. + public const float ExitStuckDisplacementSq = 0.25f; + /// Rotation speed in degrees per second for NPC facing direction. public const float RotationSpeed = 360f; } From a852e7f496763435d7e5f36385e45ec8201fe519 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Fri, 27 Mar 2026 16:32:33 -0400 Subject: [PATCH 45/64] fix(Building): replace MonoBehaviour with plain class for BuildingPartRegistry to fix IL2CPP crash --- Building/BuildingBuilder.cs | 2 +- Building/BuildingPartRegistry.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index a1beead..183d238 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -71,7 +71,7 @@ public BuildingBuilder(string name) { _name = name; _root = new GameObject(name); - _registry = _root.AddComponent(); + _registry = new BuildingPartRegistry(); _config = BuildingConfig.Default; _roomSize = _config.Size; } diff --git a/Building/BuildingPartRegistry.cs b/Building/BuildingPartRegistry.cs index 4ca207a..0dcdd50 100644 --- a/Building/BuildingPartRegistry.cs +++ b/Building/BuildingPartRegistry.cs @@ -11,7 +11,7 @@ namespace S1MAPI.Building /// Consumers use this to target specific parts (all exterior walls, just the north wall, /// the floor, trim, etc.) and get their renderers for material swaps. /// - public sealed class BuildingPartRegistry : MonoBehaviour + public sealed class BuildingPartRegistry { private readonly Dictionary> _parts = new Dictionary>(); private readonly Dictionary> _wallsByDirection = new Dictionary>(); From 9be6d4e5498eeafe62f0f983b4b6e49ae9fa79c7 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Mar 2026 09:10:56 -0400 Subject: [PATCH 46/64] fix(Building): prevent NavMeshAgent re-enable from warping NPCs out of managed buildings --- Building/InteriorNavigatorCore.cs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Building/InteriorNavigatorCore.cs b/Building/InteriorNavigatorCore.cs index 453d57a..aeb965e 100644 --- a/Building/InteriorNavigatorCore.cs +++ b/Building/InteriorNavigatorCore.cs @@ -62,6 +62,7 @@ private sealed class TrackedNPC public Vector3 LastChaseTargetLocal; // last chase target used for repath (avoids redundant recompute) public float ApproachStartTime; // Time.time when Approaching state began public bool IsStairLerp; // true during stair climb/descent lerp (Y follows ramp slope) + public Vector3 LastValidPos; // last position set by our code (restored if agent warps NPC) // Cached reflection results public object? MovementRef; // NPCMovement instance @@ -722,6 +723,17 @@ public void Update() continue; } + // The game may re-enable the NavMeshAgent at any time (e.g. NPC.SetVisible + // toggles Agent.enabled). A re-enabled agent on carved NavMesh snaps the NPC + // to ground level, overriding our position control. Disable it and restore + // the last known good position to undo the warp. + if (data.State != NPCNavState.Approaching && data.Agent != null && data.Agent.enabled) + { + data.Agent.enabled = false; + if (data.LastValidPos != Vector3.zero) + npc.transform.position = data.LastValidPos; + } + switch (data.State) { case NPCNavState.Approaching: @@ -802,6 +814,11 @@ public void Update() }); break; } + + // Save position after our code sets it, so we can restore if the + // game's NavMeshAgent warps the NPC next frame. + if (data.State != NPCNavState.Approaching) + data.LastValidPos = npc.transform.position; } foreach (Component npc in _removeQueue) @@ -998,6 +1015,10 @@ private static float HorizontalDistance(Vector3 a, Vector3 b) private void BeginDoorwayEntry(Component npc, TrackedNPC data) { + // Snapshot position before disabling agent — this is the last known good + // position for warp-back if the game re-enables the agent later. + data.LastValidPos = npc.transform.position; + // Stop the agent directly (avoid NPCMovement.Stop() which fires stale callbacks) if (data.Agent != null) { From 51cebab76a4ef6f296fbc2520a26d015aebcf9e7 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Mar 2026 09:21:46 -0400 Subject: [PATCH 47/64] fix(Building): remove Vector3.zero guard from agent warp-back restore --- Building/InteriorNavigatorCore.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Building/InteriorNavigatorCore.cs b/Building/InteriorNavigatorCore.cs index aeb965e..94bc2c8 100644 --- a/Building/InteriorNavigatorCore.cs +++ b/Building/InteriorNavigatorCore.cs @@ -730,8 +730,7 @@ public void Update() if (data.State != NPCNavState.Approaching && data.Agent != null && data.Agent.enabled) { data.Agent.enabled = false; - if (data.LastValidPos != Vector3.zero) - npc.transform.position = data.LastValidPos; + npc.transform.position = data.LastValidPos; } switch (data.State) From b3b129838f07e9ba04cc5e12481d3448a0ca1d04 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Mar 2026 09:22:41 -0400 Subject: [PATCH 48/64] docs: add missing XML summaries for Constants.Layers, Tags, and Roof fields --- Utils/Constants.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Utils/Constants.cs b/Utils/Constants.cs index b7228ff..bd6f1d8 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -24,7 +24,9 @@ internal static class Constants /// public static class Layers { + /// Default Unity layer. public const string DEFAULT = "Default"; + /// Ignore Raycast Unity layer. public const string IGNORE_RAYCAST = "Ignore Raycast"; } @@ -33,6 +35,7 @@ public static class Layers /// public static class Tags { + /// Default Unity tag for untagged objects. public const string UNTAGGED = "Untagged"; } @@ -305,11 +308,11 @@ public static class Roof /// public const string RoofSlopeMaterialName = "mansion_roof_mat"; - /// - /// Fallback roof color RGB values when material is not found. - /// + /// Fallback roof color red component. public const float DefaultRoofColorR = 0.45f; + /// Fallback roof color green component. public const float DefaultRoofColorG = 0.35f; + /// Fallback roof color blue component. public const float DefaultRoofColorB = 0.3f; } From 2cef68bcebc4443e79bbb6c635f37bd749dd4acb Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Mar 2026 09:30:17 -0400 Subject: [PATCH 49/64] fix(Building): fail fast in CreateNavigationBuilder when no exterior doorways exist --- Building/BuildingBuilder.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 183d238..364cc7c 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -378,6 +378,16 @@ public NavigationBuilder CreateNavigationBuilder() normal, interior.WallThickness, isInterior: true)); } + bool hasExterior = false; + for (int i = 0; i < doorways.Count; i++) + { + if (!doorways[i].IsInterior) { hasExterior = true; break; } + } + if (!hasExterior) + throw new System.InvalidOperationException( + "[BuildingBuilder.CreateNavigationBuilder] No exterior doorways found. " + + "NavigationBuilder requires at least one exterior door (AddWalls with a door opening)."); + return new NavigationBuilder( _root.transform, _roomSize, doorways, _config.WallThickness, _foundationHeight); From 32ac0be4b03974d729267f7c33254fa7653c3011 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Mar 2026 09:41:44 -0400 Subject: [PATCH 50/64] fix(Building): apply floor/ceiling overrides to palette before memoized builder --- Building/BuildingBuilder.cs | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 364cc7c..3aee23a 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -134,20 +134,10 @@ public BuildingBuilder DefineRoom(float width, float height, float depth) /// This builder for chaining public BuildingBuilder AddFloor(Color? color = null, Material? material = null) { - var palette = color.HasValue || material != null - ? _config.Palette.Clone().WithFloor(material!) - : _config.Palette; - - if (color.HasValue) - { - palette.FloorColor = color.Value; - } - if (material != null) - { - palette.FloorMaterial = material; - } + if (color.HasValue) _config.Palette.FloorColor = color.Value; + if (material != null) _config.Palette.FloorMaterial = material; - var floor = GetDecorBuilder(palette).AddFloor(_config.FloorThickness); + var floor = GetDecorBuilder().AddFloor(_config.FloorThickness); _registry.Register(BuildingPart.Floor, floor); return this; } @@ -160,15 +150,10 @@ public BuildingBuilder AddFloor(Color? color = null, Material? material = null) /// This builder for chaining public BuildingBuilder AddCeiling(Color? color = null, Material? material = null) { - var palette = _config.Palette; - if (color.HasValue || material != null) - { - palette = palette.Clone(); - if (color.HasValue) palette.CeilingColor = color.Value; - if (material != null) palette.CeilingMaterial = material; - } + if (color.HasValue) _config.Palette.CeilingColor = color.Value; + if (material != null) _config.Palette.CeilingMaterial = material; - var ceiling = GetDecorBuilder(palette).AddCeiling(_config.CeilingThickness); + var ceiling = GetDecorBuilder().AddCeiling(_config.CeilingThickness); _registry.Register(BuildingPart.Ceiling, ceiling); return this; } @@ -849,9 +834,9 @@ private LightingBuilder GetLightingBuilder() return _lightingBuilder ??= new LightingBuilder(_root.transform, _roomSize, _config.Palette); } - private DecorBuilder GetDecorBuilder(BuildingPalette? palette = null) + private DecorBuilder GetDecorBuilder() { - return _decorBuilder ??= new DecorBuilder(_root.transform, _roomSize, palette ?? _config.Palette); + return _decorBuilder ??= new DecorBuilder(_root.transform, _roomSize, _config.Palette); } private RoofBuilder GetRoofBuilder() From ea92629cb967dc038ed1287da91ce4d7331f7ea1 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Mar 2026 09:55:50 -0400 Subject: [PATCH 51/64] fix(Building): use real stair params in ComputeStairBasePosition nav target --- Building/BuildingBuilder.cs | 67 +++++++++++++++++++++++------ Building/Structural/DecorBuilder.cs | 2 +- Utils/Constants.cs | 6 ++- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 3aee23a..cce1a1c 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -50,8 +50,39 @@ public sealed class BuildingBuilder // Foundation and stair tracking for NavMesh link computation private float _foundationHeight; - private readonly List<(WallSide Wall, float FoundationHeight, float Width, float Offset)> _stairs = - new List<(WallSide, float, float, float)>(); + private float _foundationExpandX; + private float _foundationExpandZ; + private readonly List _stairs = new List(); + + /// + /// Internal record capturing the full stair specification so + /// can reproduce the exact run distance. + /// + private readonly struct StairSpec + { + public readonly WallSide Wall; + public readonly float FoundationHeight; + public readonly float Width; + public readonly float LateralOffset; + public readonly float MaxStepHeight; + public readonly float StepDepth; + public readonly StairStyle Style; + public readonly bool FlushWithFloor; + + public StairSpec(WallSide wall, float foundationHeight, float width, + float lateralOffset, float maxStepHeight, float stepDepth, + StairStyle style, bool flushWithFloor) + { + Wall = wall; + FoundationHeight = foundationHeight; + Width = width; + LateralOffset = lateralOffset; + MaxStepHeight = maxStepHeight; + StepDepth = stepDepth; + Style = style; + FlushWithFloor = flushWithFloor; + } + } // Stored wall openings for cross-builder communication (e.g., base molding gap) private WallOpening? _northOpening; @@ -534,6 +565,8 @@ public BuildingBuilder AddCornerTrim(float width = 0.3f, float depth = 0.1f, Mat public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, float expandZ = 0f, Color? color = null, Material? material = null) { _foundationHeight = height; + _foundationExpandX = expandX; + _foundationExpandZ = expandZ; var foundation = GetDecorBuilder().AddFoundation(height, expandX, expandZ, color, material); _registry.Register(BuildingPart.Foundation, foundation); return this; @@ -569,7 +602,8 @@ public BuildingBuilder AddStairs( float gap = 0f) { float lateralOffset = GetDoorOffset(wall); - _stairs.Add((wall, foundationHeight, width, lateralOffset)); + _stairs.Add(new StairSpec(wall, foundationHeight, width, lateralOffset, + maxStepHeight, stepDepth, style, flushWithFloor)); var stairs = GetDecorBuilder().AddStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, style, flushWithFloor, gap, lateralOffset); _registry.Register(BuildingPart.Stairs, stairs); return this; @@ -957,22 +991,29 @@ private void TryAddExteriorDoor( { if (_foundationHeight <= 0f) return null; - // Find stair entry for this wall - foreach ((WallSide stairWall, float foundationHeight, float width, float offset) in _stairs) + foreach (StairSpec spec in _stairs) { - if (stairWall != wall) continue; + if (spec.Wall != wall) continue; + + // Mirror the step count and visible steps logic from DecorBuilder stair builders + int stepCount = Mathf.Max(2, Mathf.CeilToInt(spec.FoundationHeight / spec.MaxStepHeight)); + int visibleSteps = spec.Style == StairStyle.ClosedRiser || spec.FlushWithFloor + ? stepCount + : stepCount - 1; + + // Match the clearance used by DecorBuilder: padding + foundation expand + bool isNorthSouth = wall == WallSide.North || wall == WallSide.South; + float foundationClearance = Constants.Spatial.FoundationPadding + + (isNorthSouth ? _foundationExpandZ : _foundationExpandX); - // Compute stair run from foundation height using default step parameters - int stepCount = Mathf.Max(2, Mathf.CeilToInt(foundationHeight / Constants.Spatial.DefaultMaxStepHeight)); - int visibleSteps = stepCount - 1; - float clearance = Constants.Spatial.StairTopClearance; - float stairRun = visibleSteps * Constants.Spatial.DefaultStepDepth - + Constants.Spatial.DefaultStepDepth / 2f + clearance; + // Bottom step far edge distance from wall = visibleSteps * stepDepth + clearance + // Add half a step depth as standing buffer beyond the stair edge + float stairRun = visibleSteps * spec.StepDepth + spec.StepDepth / 2f + foundationClearance; Vector3 outward = -inwardNormal; Vector3 stairBaseXZ = doorCenter + outward * stairRun; - return new Vector3(stairBaseXZ.x, -foundationHeight, stairBaseXZ.z); + return new Vector3(stairBaseXZ.x, -spec.FoundationHeight, stairBaseXZ.z); } return null; diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index f416a92..05c957e 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -293,7 +293,7 @@ public GameObject AddFoundation(float height = 2.0f, float expandX = 0f, float e Color foundationColor = color ?? new Color(0.4f, 0.4f, 0.4f); GameObject container = BuildingUtilities.CreateFolder("Foundation", _parent); - float padding = 0.1f; + float padding = Constants.Spatial.FoundationPadding; float yOffset = -0.001f; // Avoid z-fighting with floor // Store clearance so AddStairs can auto-clear the foundation edge diff --git a/Utils/Constants.cs b/Utils/Constants.cs index bd6f1d8..0a4b23d 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -144,9 +144,11 @@ public static class Spatial public const float DefaultStepDepth = 0.3f; /// - /// Clearance gap between the top stair step and the foundation edge in meters. + /// Base padding around the foundation block in meters. + /// Used by DecorBuilder to offset the foundation beyond room bounds + /// and by BuildingBuilder to compute stair clearance for navigation. /// - public const float StairTopClearance = 0.2f; + public const float FoundationPadding = 0.1f; /// /// GameObject folder name for stair geometry under the building root. From 9be0a3f0b085f6ea02b0662939800fbd2a4fe872 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Mar 2026 09:57:26 -0400 Subject: [PATCH 52/64] fix(Building): guard against negative submeshIndex in SetSubmeshMaterial --- Building/BuildingPartRegistry.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Building/BuildingPartRegistry.cs b/Building/BuildingPartRegistry.cs index 0dcdd50..213a1ae 100644 --- a/Building/BuildingPartRegistry.cs +++ b/Building/BuildingPartRegistry.cs @@ -202,7 +202,7 @@ private static void SetSubmeshMaterial(Renderer[] renderers, Material material, for (int i = 0; i < renderers.Length; i++) { Material[] mats = renderers[i].materials; - if (submeshIndex < mats.Length) + if (submeshIndex >= 0 && submeshIndex < mats.Length) { mats[submeshIndex] = material; renderers[i].materials = mats; From 7bcd984dc33788dc940832f07253abdd63dfe637 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Mar 2026 12:18:03 -0400 Subject: [PATCH 53/64] refactor(Building): use NavMeshAgent/CharacterController for living entity detection --- Building/BuildingUtilities.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Building/BuildingUtilities.cs b/Building/BuildingUtilities.cs index 997aa97..2eaf233 100644 --- a/Building/BuildingUtilities.cs +++ b/Building/BuildingUtilities.cs @@ -151,23 +151,23 @@ public static float ComputeGridCellSize(float roomX, float roomZ) /// /// Check whether a transform belongs to a living entity (player/NPC) by - /// looking for an anywhere in its root hierarchy. + /// looking for a or + /// anywhere in its root hierarchy. /// Results are cached in the provided sets for efficiency. /// - /// true if the transform's root contains an Animator. + /// true if the transform's root contains a NavMeshAgent or CharacterController. internal static bool IsLivingEntity(Transform t, HashSet livingRoots, HashSet staticRoots) { int rootId = t.root.GetInstanceID(); if (livingRoots.Contains(rootId)) return true; if (staticRoots.Contains(rootId)) return false; - if (t.root.GetComponentInChildren() != null) - { - livingRoots.Add(rootId); - return true; - } - staticRoots.Add(rootId); - return false; + Transform root = t.root; + bool isLiving = root.GetComponentInChildren() != null + || root.GetComponentInChildren() != null; + + (isLiving ? livingRoots : staticRoots).Add(rootId); + return isLiving; } #endregion From 980e71f5176260461f7b2970d30580272795861d Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sat, 28 Mar 2026 18:34:59 -0400 Subject: [PATCH 54/64] fix(Building): restore HasDestination flag in ReleaseAllNPCs during cleanup --- Building/InteriorNavigatorCore.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Building/InteriorNavigatorCore.cs b/Building/InteriorNavigatorCore.cs index 94bc2c8..2c1887d 100644 --- a/Building/InteriorNavigatorCore.cs +++ b/Building/InteriorNavigatorCore.cs @@ -687,6 +687,14 @@ public void ReleaseAllNPCs() EnableAgent(data); } + // Restore HasDestination so the game's pursuit AI resumes generating + // SetDestination calls (mirrors ReleaseNPC logic) + if (data.MovementRef != null && _hasDestinationAccessor.IsValid) + { + try { _hasDestinationAccessor.SetValue(data.MovementRef, true); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] RestoreHasDestination failed: {ex.Message}"); } + } + _globallyManaged.Remove(kvp.Key); } _tracked.Clear(); From d9647ef93303519a1922f7f1bb97cbca13cbc829 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 29 Mar 2026 13:39:26 -0400 Subject: [PATCH 55/64] fix(Building): use ReferenceEquals for chase target null check to detect destroyed objects --- Building/InteriorNavigatorCore.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Building/InteriorNavigatorCore.cs b/Building/InteriorNavigatorCore.cs index 2c1887d..5b5dc32 100644 --- a/Building/InteriorNavigatorCore.cs +++ b/Building/InteriorNavigatorCore.cs @@ -1136,12 +1136,13 @@ private void UpdateInside(Component npc, TrackedNPC data) Vector3 pos = npc.transform.position; // Chase mode: periodically re-pathfind toward moving target. - // Two-stage null check: first tests C# reference (was a target assigned?), - // second tests Unity's operator== (was the GameObject destroyed at runtime?). - if (data.ChaseTarget != null) + // ReferenceEquals bypasses Unity's overloaded == to test "was a target assigned?" + // Then Unity's == detects destroyed objects (native pointer gone, proxy alive). + if (!ReferenceEquals(data.ChaseTarget, null)) { if (data.ChaseTarget == null) { + // Target was assigned but Unity object was destroyed — exit building BeginExit(npc, data); return; } From 90ee62825ad9c0307bd92ecc9300fbab83866c7d Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 29 Mar 2026 14:30:19 -0400 Subject: [PATCH 56/64] feat(Building): expose walkability queries on NavigationBuilder --- Building/InteriorNavigatorCore.cs | 2 ++ Building/NavigationBuilder.cs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Building/InteriorNavigatorCore.cs b/Building/InteriorNavigatorCore.cs index 5b5dc32..b8595ea 100644 --- a/Building/InteriorNavigatorCore.cs +++ b/Building/InteriorNavigatorCore.cs @@ -104,6 +104,8 @@ public void SetValue(object target, object? value) #region Fields private readonly InteriorPathGrid _grid; + /// Exposes the walkability grid to NavigationBuilder pass-through queries. + internal InteriorPathGrid PathGrid => _grid; private readonly IReadOnlyList _doorways; private readonly Transform _buildingRoot; private readonly Vector3 _roomSize; diff --git a/Building/NavigationBuilder.cs b/Building/NavigationBuilder.cs index 2a6f04c..3089e34 100644 --- a/Building/NavigationBuilder.cs +++ b/Building/NavigationBuilder.cs @@ -295,6 +295,36 @@ public void Remove() #endregion + #region Public API — Walkability + + /// + /// Check if a local-space position is on a walkable grid cell. + /// Returns false when the navigation system has not been built yet. + /// + /// Position in building-local coordinates. + /// True if the cell at is walkable. + public bool IsWalkable(Vector3 localPos) + => _navCore?.PathGrid?.IsWalkable(localPos) ?? false; + + /// + /// Get the center of the nearest walkable grid cell in local coordinates. + /// Returns unchanged when the navigation system has not been built yet. + /// + /// Position in building-local coordinates. + /// The center of the nearest walkable cell, or if unavailable. + public Vector3 NearestWalkableCell(Vector3 localPos) + => _navCore?.PathGrid?.NearestWalkableCell(localPos) ?? localPos; + + /// + /// Grid cell size in meters. Matches the resolution used by interior pathfinding + /// so callers can iterate cells at the same spacing. + /// Returns 0.5 when the navigation system has not been built yet. + /// + public float CellSize + => _navCore?.PathGrid?.CellSize ?? 0.5f; + + #endregion + #region Private — NavMesh Carving /// From f988e87ad8031afc8530262fcb12d8a78c475f23 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 29 Mar 2026 15:00:06 -0400 Subject: [PATCH 57/64] fix(Building): clamp door/window offsets to wall bounds with warning --- Building/Structural/WallBuilder.cs | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index 65edfa3..eca45b7 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -348,12 +348,18 @@ private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 w float wallHeight = wallSize.y; float doorWidth = opening.Width; float doorHeight = opening.Height; - float offset = opening.Offset; + + float halfMargin = (wallWidth - doorWidth) / 2f; + float offset = Mathf.Clamp(opening.Offset, -halfMargin, halfMargin); + if (!Mathf.Approximately(offset, opening.Offset)) + DebugLog.Warning( + $"[WallBuilder] {_currentSide} wall: door offset {opening.Offset:F2}m " + + $"exceeds wall margin (±{halfMargin:F2}m) and was clamped to {offset:F2}m."); // Positive offset shifts door toward positive axis (right/forward) // Left (negative direction) gets bigger, right gets smaller - float leftWidth = (wallWidth - doorWidth) / 2f + offset; - float rightWidth = (wallWidth - doorWidth) / 2f - offset; + float leftWidth = halfMargin + offset; + float rightWidth = halfMargin - offset; // Door center shifted by offset along the wall axis Vector3 doorShift = isVertical ? Vector3.forward * offset : Vector3.right * offset; @@ -545,10 +551,17 @@ private void CreateWindowInSection( float halfHeight = sectionHeight / 2f; float windowCenterY = (windowBottom + windowHeight / 2f) - halfHeight; + // Clamp window offset so the window cannot extend past the section bounds + float clampedOffset = Mathf.Clamp(windowOffset, -sideWidth, sideWidth); + if (!Mathf.Approximately(clampedOffset, windowOffset)) + DebugLog.Warning( + $"[WallBuilder] {_currentSide} wall: window offset {windowOffset:F2}m " + + $"exceeds section margin (±{sideWidth:F2}m) and was clamped to {clampedOffset:F2}m."); + // Window center shifted along wall axis (positive = +Z for vertical, +X for horizontal) Vector3 winShift = isVertical - ? new Vector3(0f, 0f, windowOffset) - : new Vector3(windowOffset, 0f, 0f); + ? new Vector3(0f, 0f, clampedOffset) + : new Vector3(clampedOffset, 0f, 0f); Vector3 windowCenter = sectionCenter + winShift; // Bottom segment (sill) — full section width, no shift @@ -574,8 +587,8 @@ private void CreateWindowInSection( // Side segments — asymmetric widths when window is offset // For isVertical: +Z side = sideWidth - offset, -Z side = sideWidth + offset // For non-vertical: -X side = sideWidth + offset, +X side = sideWidth - offset - float posSideWidth = sideWidth - windowOffset; - float negSideWidth = sideWidth + windowOffset; + float posSideWidth = sideWidth - clampedOffset; + float negSideWidth = sideWidth + clampedOffset; float leftSideWidth = isVertical ? posSideWidth : negSideWidth; float rightSideWidth = isVertical ? negSideWidth : posSideWidth; From 2da788b1bb3ab6e8b39e929ffd0e27226cf457d8 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 29 Mar 2026 15:03:57 -0400 Subject: [PATCH 58/64] fix(Building): trigger dual-material walls on interior color-only overrides --- Building/Structural/WallBuilder.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index eca45b7..431b0f2 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -183,6 +183,7 @@ public sealed class WallBuilder private WallSide _currentSide; private Material? _currentInteriorMaterial; private Color _currentInteriorColor; + private bool _hasInteriorOverride; #endregion @@ -281,6 +282,7 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) _currentSide = side; _currentInteriorMaterial = GetWallInteriorMaterial(side); _currentInteriorColor = GetWallInteriorColor(side); + _hasInteriorOverride = _currentInteriorMaterial != null || HasExplicitInteriorColor(side); var (position, size, isVertical) = GetWallTransform(side); string wallName = $"{side}Wall"; @@ -768,15 +770,24 @@ private Color GetWallInteriorColor(WallSide side) return _palette.InteriorWallColor ?? _palette.WallColor; } + private bool HasExplicitInteriorColor(WallSide side) + { + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.InteriorColor.HasValue) + return true; + return _palette.InteriorWallColor.HasValue; + } + /// - /// Unified wall segment creation. When an interior material is active, + /// Unified wall segment creation. When an interior override is active, /// creates a dual-material mesh; otherwise delegates to the standard /// path. /// private GameObject CreateWallSegment(string name, Vector3 position, Vector3 size, Color wallColor, Material? wallMaterial, Transform parent) { - if (_currentInteriorMaterial != null) + if (_hasInteriorOverride) return CreateDualMaterialBox(name, position, size, wallColor, wallMaterial, parent); GameObject wall = PrimitiveBuilder.CreateBox(name, position, size, wallColor, parent); From acfe35d460d30679064b46efe41455e92e6e613f Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 29 Mar 2026 15:21:57 -0400 Subject: [PATCH 59/64] docs(ProceduralMesh): fix generic syntax in OrganicShapeGenerator XML example --- ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs b/ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs index dfe9932..d8e134a 100644 --- a/ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs +++ b/ProceduralMesh/Generators/Organic/OrganicShapeGenerator.cs @@ -16,7 +16,7 @@ namespace S1MAPI.ProceduralMesh.Generators.Organic /// /// public class PetMeshGenerator : OrganicShapeGenerator /// { - /// protected override void GenerateGeometry(List{Vector3} vertices, List{int} triangles) + /// protected override void GenerateGeometry(List<Vector3> vertices, List<int> triangles) /// { /// // Use helper methods like AddRing, AddJointRing, ConnectRings /// AddRing(vertices, position: Vector3.zero, radius: 1f, segments: 12); From 111c39f054c17dd37f02ca5f4c0c2c503ed55dcd Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 29 Mar 2026 15:33:06 -0400 Subject: [PATCH 60/64] fix(Building): reject WithInteriorWallLayer after AddInteriorWall --- Building/BuildingBuilder.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index cce1a1c..2c06980 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -315,6 +315,9 @@ public BuildingBuilder AddWalls( /// This builder for chaining public BuildingBuilder WithInteriorWallLayer(int layer) { + if (_interiorWallBuilder != null) + throw new System.InvalidOperationException( + "[BuildingBuilder.WithInteriorWallLayer] Must be called before AddInteriorWall."); _interiorWallLayer = layer; return this; } From 58fa0518aebaa9f81baf1bf4e7ddf31c7d8dc281 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 29 Mar 2026 17:06:08 -0400 Subject: [PATCH 61/64] fix(Building): move AddSlidingDoors onCreated callback to pre-activation window --- Building/BuildingBuilder.cs | 10 ++++------ Building/Components/PrefabPlacer.cs | 25 ++++++++++++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 2c06980..914f5ad 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -769,16 +769,14 @@ public BuildingBuilder AddPrefab(PrefabRef prefab, Vector3 position, Quaternion /// Local position for doors /// Local rotation /// Text for opening hours sign - /// Optional callback invoked with the instantiated door GameObject. + /// Optional callback invoked with the instantiated door GameObject + /// before activation. Runs after internal customization (material, opening hours text) but + /// before Awake/OnEnable fire, so sensors see configured values. /// Only fires on the server — will NOT fire on clients. /// This builder for chaining public BuildingBuilder AddSlidingDoors(Vector3 position, Quaternion rotation, string openingHours = "6AM-6PM", Action? onCreated = null) { - GameObject? instance = GetPrefabPlacer().PlaceSlidingDoors(position, rotation, openingHours, Materials.MetalDarkGrey); - if (instance != null) - { - onCreated?.Invoke(instance); - } + GetPrefabPlacer().PlaceSlidingDoors(position, rotation, openingHours, Materials.MetalDarkGrey, onCreated); return this; } diff --git a/Building/Components/PrefabPlacer.cs b/Building/Components/PrefabPlacer.cs index 5c10c49..b7f0b6b 100644 --- a/Building/Components/PrefabPlacer.cs +++ b/Building/Components/PrefabPlacer.cs @@ -112,14 +112,29 @@ public PrefabPlacer(Transform parent) /// Local rotation /// Text to display for opening hours /// Optional material for door panels + /// Optional callback invoked on the door GameObject before activation, + /// server-only. Runs after internal customization (material, opening hours text) but before + /// Awake/OnEnable fire, so sensors see configured values. Does not fire on clients. /// The door instance on server, or null on clients / if prefab not found - public GameObject? PlaceSlidingDoors(Vector3 localPosition, Quaternion localRotation, string openingHoursText = "6AM-6PM", Material? doorMaterial = null) + public GameObject? PlaceSlidingDoors(Vector3 localPosition, Quaternion localRotation, string openingHoursText = "6AM-6PM", Material? doorMaterial = null, Action? onServerReady = null) { Material? mat = doorMaterial; string text = openingHoursText; - return PlaceInternal(Prefabs.SlidingDoors, localPosition, localRotation, networked: true, - (go) => CustomizeSlidingDoors(go, mat, text)); + // Internal customization fires on both server and client (via deferred linker). + // Consumer callback fires server-only, still pre-activation. + bool deferActivation = onServerReady != null; + GameObject? instance = PlaceInternal(Prefabs.SlidingDoors, localPosition, localRotation, networked: true, + (go) => CustomizeSlidingDoors(go, mat, text), activate: !deferActivation); + + if (instance != null && deferActivation) + { + onServerReady!(instance); + if (!instance.activeSelf) + instance.SetActive(true); + } + + return instance; } /// @@ -171,7 +186,7 @@ public PrefabPlacer(Transform parent) /// so the FishNet-replicated object is parented and customized when it arrives. /// private GameObject? PlaceInternal(PrefabRef prefab, Vector3 localPosition, Quaternion localRotation, - bool networked, Action? onReady) + bool networked, Action? onReady, bool activate = true) { // For networked prefabs, instantiate WITHOUT activating so onReady can // configure components before Awake/OnEnable fire. This prevents sensors @@ -222,7 +237,7 @@ public PrefabPlacer(Transform parent) onReady?.Invoke(instance); // Activate AFTER onReady so sensors/triggers see configured values, not prefab defaults. - if (networked && !instance.activeSelf) + if (activate && networked && !instance.activeSelf) instance.SetActive(true); return instance; From 3207abf37855f417868d284c8407683688946735 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 29 Mar 2026 17:40:11 -0400 Subject: [PATCH 62/64] fix(Building): return false for out-of-bounds positions in IsWalkable --- Building/InteriorPathGrid.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Building/InteriorPathGrid.cs b/Building/InteriorPathGrid.cs index 532fa8e..13ff57c 100644 --- a/Building/InteriorPathGrid.cs +++ b/Building/InteriorPathGrid.cs @@ -108,8 +108,8 @@ public void Regenerate() /// public bool IsWalkable(Vector3 localPos) { - int gx = LocalToGridX(localPos.x); - int gz = LocalToGridZ(localPos.z); + int gx = Mathf.FloorToInt(localPos.x / _cellSize); + int gz = Mathf.FloorToInt(localPos.z / _cellSize); return IsWalkableCell(gx, gz); } From 1d3dcd742600e5535154ab8dc332eb2f4aa33106 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Sun, 29 Mar 2026 17:46:15 -0400 Subject: [PATCH 63/64] fix(Building): include foundation expansion in FlattenTerrain footprint --- Building/BuildingBuilder.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 914f5ad..a3f18e1 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -424,8 +424,12 @@ public BuildingBuilder FlattenTerrain( bool clearDetails = true, float blendDistance = Constants.Terrain.DefaultBlendDistance) { float targetWorldY = _root.transform.position.y - _foundationHeight; + Vector3 footprint = new Vector3( + _roomSize.x + _foundationExpandX * 2f, + _roomSize.y, + _roomSize.z + _foundationExpandZ * 2f); TerrainFlattener.FlattenUnder( - _root, _roomSize, targetWorldY, padding, clearDetails, blendDistance); + _root, footprint, targetWorldY, padding, clearDetails, blendDistance); return this; } From 11b1aa25aed71653ee2cc7de13aee88c5bd52021 Mon Sep 17 00:00:00 2001 From: hdlmrell Date: Mon, 30 Mar 2026 00:58:16 -0400 Subject: [PATCH 64/64] fix(Building): preserve interior doorway metadata across InvalidateBuilders --- Building/BuildingBuilder.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index a3f18e1..0389f3b 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -48,6 +48,9 @@ public sealed class BuildingBuilder // Interior wall physics layer (-1 = default layer, no change) private int _interiorWallLayer = -1; + // Cached interior doorway metadata survives InvalidateBuilders() + private IReadOnlyList? _interiorDoorwaysCache; + // Foundation and stair tracking for NavMesh link computation private float _foundationHeight; private float _foundationExpandX; @@ -368,7 +371,7 @@ public BuildingBuilder AddInteriorWall( /// Each entry provides center, dimensions, and orientation for future NavMesh link generation. /// public IReadOnlyList InteriorDoorways => - _interiorWallBuilder?.Doorways ?? (IReadOnlyList)System.Array.Empty(); + _interiorWallBuilder?.Doorways ?? _interiorDoorwaysCache ?? (IReadOnlyList)System.Array.Empty(); /// /// Create a configured for this building. @@ -844,6 +847,10 @@ public GameObject Build(Action postBuild) private void InvalidateBuilders() { + // Preserve interior doorway metadata before clearing the builder + if (_interiorWallBuilder != null && _interiorWallBuilder.Doorways.Count > 0) + _interiorDoorwaysCache = _interiorWallBuilder.Doorways; + _wallBuilder = null; _furnitureBuilder = null; _lightingBuilder = null;