From 5c6657d9d0c2c83924995909751c4019e8325f2a Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Thu, 9 Jul 2026 20:07:54 +0200 Subject: [PATCH] PictureFrameBuilder: auto box colliders + coalesced rebuild in Update Rebuild now runs once per frame via ExecuteAlways Update (OnValidate only sets a dirty flag + QueuePlayerLoopUpdate), fixing inspector drag stutter from stacked delayCalls. Adds automatic BoxCollider generation (4 frame bars, opening slab, optional pedestal box for raised pictures) with an ownership list so manual colliders stay untouched. Corner radius is intentionally ignored for colliders. Co-Authored-By: Claude Fable 5 --- .../Scripts/Components/PictureFrameBuilder.cs | 77 +++++++++++++++++-- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/Components/PictureFrameBuilder.cs b/Assets/Scripts/Components/PictureFrameBuilder.cs index 13059a7..62fc400 100644 --- a/Assets/Scripts/Components/PictureFrameBuilder.cs +++ b/Assets/Scripts/Components/PictureFrameBuilder.cs @@ -13,6 +13,10 @@ namespace Chameleon.Components // The generated mesh is a per-instance HideFlags.DontSave object (never saved into the scene file); // it is rebuilt on scene load / domain reload via OnEnable. Duplicated frames get their own mesh // (identified by instance id), so two frames never write into one shared mesh. + // + // GenerateColliders maintains matching BoxColliders (corner radius ignored): 4 border bars, a slab + // behind the inner floor, plus a plinth box when the picture is raised. Only colliders tracked in + // _colliders are touched - manually added ones are left alone. [ExecuteAlways] [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public sealed class PictureFrameBuilder : MonoBehaviour @@ -52,6 +56,12 @@ namespace Chameleon.Components [SerializeField] Material _frameMaterial; [SerializeField] Material _backMaterial; + [Header("Colliders")] + [Tooltip("Automatically maintain BoxColliders matching the frame (corner radius is ignored).")] + [SerializeField] bool _generateColliders = true; + // Ownership list so rebuilds never touch colliders the user added by hand. + [SerializeField, HideInInspector] List _colliders = new List(); + readonly List _verts = new List(); readonly List _normals = new List(); readonly List _uvs = new List(); @@ -62,23 +72,31 @@ namespace Chameleon.Components Mesh _mesh; bool _rebuildQueued; + bool _rebuilding; void OnEnable() => Rebuild(); + // Mesh/renderer writes are not allowed during OnValidate, so it only flags; the actual + // rebuild runs in Update (ExecuteAlways covers edit mode). void OnValidate() { -#if UNITY_EDITOR - // Mesh/renderer writes are not allowed during OnValidate; defer one editor tick. - if (_rebuildQueued) return; _rebuildQueued = true; - UnityEditor.EditorApplication.delayCall += () => - { - _rebuildQueued = false; - if (this && isActiveAndEnabled) Rebuild(); - }; +#if UNITY_EDITOR + // In edit mode Update only ticks when the editor decides to pump the player loop; + // request one so the last inspector change never stays un-applied. + UnityEditor.EditorApplication.QueuePlayerLoopUpdate(); #endif } + void Update() + { + if (!_rebuildQueued || _rebuilding) return; + _rebuildQueued = false; + _rebuilding = true; + try { Rebuild(); } + finally { _rebuilding = false; } + } + void OnDestroy() { if (!_mesh) return; @@ -150,6 +168,49 @@ namespace Chameleon.Components _mesh.RecalculateTangents(); ApplyMaterials(); + RebuildColliders(w, h, t, d, ow, oh, pw, ph, id, pz); + } + + // Box approximation, corner radius intentionally ignored: 4 border bars (full depth), one slab + // filling the opening from the inner floor to the back, and a plinth box only when the picture + // is raised out of the floor. A sunken picture recess is NOT carved out - the slab stays full + // (colliders would need a per-side split for a cosmetic dent). + void RebuildColliders(float w, float h, float t, float d, float ow, float oh, float pw, float ph, float id, float pz) + { + _colliders.RemoveAll(c => !c); + int count = 0; + if (_generateColliders) + { + SetBox(count++, new Vector3(0f, (h - t) * 0.5f, d * 0.5f), new Vector3(w, t, d)); + SetBox(count++, new Vector3(0f, -(h - t) * 0.5f, d * 0.5f), new Vector3(w, t, d)); + SetBox(count++, new Vector3((w - t) * 0.5f, 0f, d * 0.5f), new Vector3(t, oh, d)); + SetBox(count++, new Vector3(-(w - t) * 0.5f, 0f, d * 0.5f), new Vector3(t, oh, d)); + + // Keep a minimal slab even when the inner floor sits on the back plate, so the opening + // is never backless. + float slabFront = Mathf.Min(id, d - 0.002f); + SetBox(count++, new Vector3(0f, 0f, (slabFront + d) * 0.5f), new Vector3(ow, oh, d - slabFront)); + + if (pz < id - 1e-4f) + SetBox(count++, new Vector3(0f, 0f, (pz + id) * 0.5f), new Vector3(pw, ph, id - pz)); + } + + for (int i = _colliders.Count - 1; i >= count; i--) + { + var extra = _colliders[i]; + _colliders.RemoveAt(i); + if (!extra) continue; + if (Application.isPlaying) Destroy(extra); + else DestroyImmediate(extra); + } + } + + void SetBox(int index, Vector3 center, Vector3 size) + { + while (_colliders.Count <= index) _colliders.Add(gameObject.AddComponent()); + var box = _colliders[index]; + if (box.center != center) box.center = center; + if (box.size != size) box.size = size; } void EnsureMesh(MeshFilter filter)