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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-09 20:07:54 +02:00
1 parent a4d4c9ac91
commit 5c6657d9d0
1 file changed
+69 -8
@@ -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<BoxCollider> _colliders = new List<BoxCollider>();
readonly List<Vector3> _verts = new List<Vector3>();
readonly List<Vector3> _normals = new List<Vector3>();
readonly List<Vector2> _uvs = new List<Vector2>();
@@ -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<BoxCollider>());
var box = _colliders[index];
if (box.center != center) box.center = center;
if (box.size != size) box.size = size;
}
void EnsureMesh(MeshFilter filter)