From 210d0adc3e79d07fcd3f8d802390f87fd336c5a4 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Thu, 9 Jul 2026 19:19:59 +0200 Subject: [PATCH] Add PictureFrameBuilder procedural picture frame component Editor-tunable frame mesh: sliced scaling (constant profile), 4 material submeshes (picture/interior/frame/back), padding, frame thickness/depth, rounded outer edges (quarter-cylinder strips + eighth-sphere corner caps). Frame Inner Depth sets the interior plane; Picture Depth is relative to it (positive = recessed, negative = raised pedestal) with auto perimeter walls. Meshes are per-instance, DontSave, rebuilt in OnEnable/OnValidate. Co-Authored-By: Claude Fable 5 --- .../Scripts/Components/PictureFrameBuilder.cs | 334 ++++++++++++++++++ .../Components/PictureFrameBuilder.cs.meta | 2 + 2 files changed, 336 insertions(+) create mode 100644 Assets/Scripts/Components/PictureFrameBuilder.cs create mode 100644 Assets/Scripts/Components/PictureFrameBuilder.cs.meta diff --git a/Assets/Scripts/Components/PictureFrameBuilder.cs b/Assets/Scripts/Components/PictureFrameBuilder.cs new file mode 100644 index 0000000..13059a7 --- /dev/null +++ b/Assets/Scripts/Components/PictureFrameBuilder.cs @@ -0,0 +1,334 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Chameleon.Components +{ + // Procedural picture frame: a box-profile frame with a recessed inner floor (FrameInnerDepth) and a + // picture plane offset relative to it (PictureDepth: + sunken, - raised plinth), generated live in the + // editor (and at runtime) from the inspector parameters - resize via Width/Height, NOT via transform + // scale, so the frame profile never stretches (9-slice behaviour). Four submeshes with their own + // material slots: 0 picture, 1 inner cavity (recess walls + padding ring), 2 frame (front ring, outer + // sides, edge rounding), 3 back plate. Front faces -Z like Unity's built-in Quad. + // + // 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. + [ExecuteAlways] + [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] + public sealed class PictureFrameBuilder : MonoBehaviour + { + const int SubPicture = 0; + const int SubInner = 1; + const int SubFrame = 2; + const int SubBack = 3; + + [Header("Size")] + [Tooltip("Outer width of the frame in meters.")] + [SerializeField, Min(0.01f)] float _width = 1f; + [Tooltip("Outer height of the frame in meters.")] + [SerializeField, Min(0.01f)] float _height = 1f; + + [Header("Frame")] + [Tooltip("Border width of the frame profile in the picture plane.")] + [SerializeField, Min(0.001f)] float _frameThickness = 0.08f; + [Tooltip("Depth of the frame, front face to back face.")] + [SerializeField, Min(0.002f)] float _frameDepth = 0.12f; + [Tooltip("Rounding radius of the outer frame edges; 0 = sharp box. Clamped to thickness / half depth.")] + [SerializeField, Min(0f)] float _cornerRadius; + [Tooltip("Arc segments of the edge rounding.")] + [SerializeField, Range(1, 12)] int _bevelSegments = 4; + [Tooltip("How deep behind the front face the inner floor (padding ring) sits.")] + [SerializeField, Min(0f)] float _frameInnerDepth = 0.06f; + + [Header("Picture")] + [Tooltip("Gap between the frame's inner edge and the picture (visible inner-material ring).")] + [SerializeField, Min(0f)] float _padding = 0.02f; + [Tooltip("Picture plane relative to the inner floor: positive sinks it into the frame, negative raises it out like a plinth, 0 = flush.")] + [SerializeField] float _pictureDepth; + + [Header("Materials")] + [SerializeField] Material _pictureMaterial; + [SerializeField] Material _innerMaterial; + [SerializeField] Material _frameMaterial; + [SerializeField] Material _backMaterial; + + readonly List _verts = new List(); + readonly List _normals = new List(); + readonly List _uvs = new List(); + readonly List[] _tris = + { + new List(), new List(), new List(), new List() + }; + + Mesh _mesh; + bool _rebuildQueued; + + void OnEnable() => Rebuild(); + + 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(); + }; +#endif + } + + void OnDestroy() + { + if (!_mesh) return; + if (Application.isPlaying) Destroy(_mesh); + else DestroyImmediate(_mesh); + } + + [ContextMenu("Rebuild")] + public void Rebuild() + { + var filter = GetComponent(); + EnsureMesh(filter); + + float w = Mathf.Max(_width, 0.01f); + float h = Mathf.Max(_height, 0.01f); + float t = Mathf.Clamp(_frameThickness, 0.001f, Mathf.Min(w, h) * 0.5f - 0.001f); + float d = Mathf.Max(_frameDepth, 0.002f); + float r = Mathf.Min(_cornerRadius, Mathf.Min(t, d * 0.5f) - 0.0005f); + if (r < 1e-4f) r = 0f; + float ow = w - 2f * t; + float oh = h - 2f * t; + float pad = Mathf.Max(0f, Mathf.Min(_padding, Mathf.Min(ow, oh) * 0.5f - 0.001f)); + float pw = Mathf.Max(ow - 2f * pad, 0.001f); + float ph = Mathf.Max(oh - 2f * pad, 0.001f); + float id = Mathf.Clamp(_frameInnerDepth, 0f, d); + float pz = Mathf.Min(id + _pictureDepth, d); + + _verts.Clear(); + _normals.Clear(); + _uvs.Clear(); + for (int i = 0; i < _tris.Length; i++) _tris[i].Clear(); + + // Picture plane (UV 0..1 so the assigned texture fills it exactly). + Quad(SubPicture, + new Vector3(-pw * 0.5f, -ph * 0.5f, pz), new Vector3(pw * 0.5f, -ph * 0.5f, pz), + new Vector3(pw * 0.5f, ph * 0.5f, pz), new Vector3(-pw * 0.5f, ph * 0.5f, pz), + Vector3.back, + new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(0f, 1f)); + + // Picture side walls when the plane sits off the inner floor: sunken = inward-facing recess, + // raised = outward-facing plinth. + if (Mathf.Abs(pz - id) > 1e-4f) + PerimeterWalls(SubInner, pw * 0.5f, ph * 0.5f, Mathf.Min(pz, id), Mathf.Max(pz, id), pz > id ? -1f : 1f); + + // Inner cavity: padding ring around the picture + recess walls from the opening down to it. + Ring(SubInner, ow * 0.5f, oh * 0.5f, pw * 0.5f, ph * 0.5f, id); + if (id > 1e-4f) PerimeterWalls(SubInner, ow * 0.5f, oh * 0.5f, 0f, id, -1f); + + // Frame: mitred front ring (from the rounded-edge inset to the opening), outer sides, bevels. + Ring(SubFrame, w * 0.5f - r, h * 0.5f - r, ow * 0.5f, oh * 0.5f, 0f); + OuterSides(w, h, d, r); + if (r > 0f) Bevels(w, h, d, r, Mathf.Max(1, _bevelSegments)); + + // Back plate. + Quad(SubBack, + new Vector3(-(w * 0.5f - r), -(h * 0.5f - r), d), new Vector3(w * 0.5f - r, -(h * 0.5f - r), d), + new Vector3(w * 0.5f - r, h * 0.5f - r, d), new Vector3(-(w * 0.5f - r), h * 0.5f - r, d), + Vector3.forward, + new Vector2(-(w * 0.5f - r), -(h * 0.5f - r)), new Vector2(w * 0.5f - r, -(h * 0.5f - r)), + new Vector2(w * 0.5f - r, h * 0.5f - r), new Vector2(-(w * 0.5f - r), h * 0.5f - r)); + + _mesh.Clear(); + _mesh.SetVertices(_verts); + _mesh.SetNormals(_normals); + _mesh.SetUVs(0, _uvs); + _mesh.subMeshCount = _tris.Length; + for (int i = 0; i < _tris.Length; i++) _mesh.SetTriangles(_tris[i], i, false); + _mesh.RecalculateBounds(); + _mesh.RecalculateTangents(); + + ApplyMaterials(); + } + + void EnsureMesh(MeshFilter filter) + { + string meshName = "PictureFrame " + GetInstanceID(); + if (!_mesh) + { + // Re-adopt our own mesh after a domain reload; a duplicated object has a different + // instance id and therefore gets a fresh mesh instead of fighting over the original's. + var existing = filter.sharedMesh; + _mesh = existing && existing.name == meshName + ? existing + : new Mesh { name = meshName, hideFlags = HideFlags.DontSave }; + } + if (filter.sharedMesh != _mesh) filter.sharedMesh = _mesh; + } + + void ApplyMaterials() + { + var meshRenderer = GetComponent(); + var current = meshRenderer.sharedMaterials; + bool dirty = current.Length != 4 + || current[0] != _pictureMaterial || current[1] != _innerMaterial + || current[2] != _frameMaterial || current[3] != _backMaterial; + if (dirty) + meshRenderer.sharedMaterials = new[] { _pictureMaterial, _innerMaterial, _frameMaterial, _backMaterial }; + } + + // Flat ring between two concentric rects at depth z, facing the viewer (-Z), mitred corners. + void Ring(int sub, float ox, float oy, float ix, float iy, float z) + { + if (oy - iy > 1e-5f) + { + QuadXY(sub, new Vector3(-ix, iy, z), new Vector3(ix, iy, z), new Vector3(ox, oy, z), new Vector3(-ox, oy, z)); + QuadXY(sub, new Vector3(-ox, -oy, z), new Vector3(ox, -oy, z), new Vector3(ix, -iy, z), new Vector3(-ix, -iy, z)); + } + if (ox - ix > 1e-5f) + { + QuadXY(sub, new Vector3(ix, -iy, z), new Vector3(ox, -oy, z), new Vector3(ox, oy, z), new Vector3(ix, iy, z)); + QuadXY(sub, new Vector3(-ox, -oy, z), new Vector3(-ix, -iy, z), new Vector3(-ix, iy, z), new Vector3(-ox, oy, z)); + } + } + + void QuadXY(int sub, Vector3 a, Vector3 b, Vector3 c, Vector3 d) + => Quad(sub, a, b, c, d, Vector3.back, + new Vector2(a.x, a.y), new Vector2(b.x, b.y), new Vector2(c.x, c.y), new Vector2(d.x, d.y)); + + // Vertical walls around rect (hx, hy) spanning z0..z1; sign -1 = facing inward (recess), + // +1 = facing outward (plinth). Winding follows the normals automatically. + void PerimeterWalls(int sub, float hx, float hy, float z0, float z1, float sign) + { + Quad(sub, new Vector3(-hx, hy, z0), new Vector3(hx, hy, z0), new Vector3(hx, hy, z1), new Vector3(-hx, hy, z1), + sign * Vector3.up, new Vector2(-hx, z0), new Vector2(hx, z0), new Vector2(hx, z1), new Vector2(-hx, z1)); + Quad(sub, new Vector3(-hx, -hy, z0), new Vector3(hx, -hy, z0), new Vector3(hx, -hy, z1), new Vector3(-hx, -hy, z1), + sign * Vector3.down, new Vector2(-hx, z0), new Vector2(hx, z0), new Vector2(hx, z1), new Vector2(-hx, z1)); + Quad(sub, new Vector3(hx, -hy, z0), new Vector3(hx, hy, z0), new Vector3(hx, hy, z1), new Vector3(hx, -hy, z1), + sign * Vector3.right, new Vector2(-hy, z0), new Vector2(hy, z0), new Vector2(hy, z1), new Vector2(-hy, z1)); + Quad(sub, new Vector3(-hx, -hy, z0), new Vector3(-hx, hy, z0), new Vector3(-hx, hy, z1), new Vector3(-hx, -hy, z1), + sign * Vector3.left, new Vector2(-hy, z0), new Vector2(hy, z0), new Vector2(hy, z1), new Vector2(-hy, z1)); + } + + // Flat outer side faces, shortened by the rounding radius on every border. + void OuterSides(float w, float h, float d, float r) + { + float hx = w * 0.5f; + float hy = h * 0.5f; + Quad(SubFrame, new Vector3(hx, -hy + r, r), new Vector3(hx, hy - r, r), new Vector3(hx, hy - r, d - r), new Vector3(hx, -hy + r, d - r), + Vector3.right, new Vector2(-hy + r, r), new Vector2(hy - r, r), new Vector2(hy - r, d - r), new Vector2(-hy + r, d - r)); + Quad(SubFrame, new Vector3(-hx, -hy + r, r), new Vector3(-hx, hy - r, r), new Vector3(-hx, hy - r, d - r), new Vector3(-hx, -hy + r, d - r), + Vector3.left, new Vector2(-hy + r, r), new Vector2(hy - r, r), new Vector2(hy - r, d - r), new Vector2(-hy + r, d - r)); + Quad(SubFrame, new Vector3(-hx + r, hy, r), new Vector3(hx - r, hy, r), new Vector3(hx - r, hy, d - r), new Vector3(-hx + r, hy, d - r), + Vector3.up, new Vector2(-hx + r, r), new Vector2(hx - r, r), new Vector2(hx - r, d - r), new Vector2(-hx + r, d - r)); + Quad(SubFrame, new Vector3(-hx + r, -hy, r), new Vector3(hx - r, -hy, r), new Vector3(hx - r, -hy, d - r), new Vector3(-hx + r, -hy, d - r), + Vector3.down, new Vector2(-hx + r, r), new Vector2(hx - r, r), new Vector2(hx - r, d - r), new Vector2(-hx + r, d - r)); + } + + // Rounded outer edges: 8 quarter-cylinder edge strips (front + back), 4 vertical corner + // quarter-cylinders and 8 sphere-octant corner caps. + void Bevels(float w, float h, float d, float r, int segs) + { + float hx = w * 0.5f; + float hy = h * 0.5f; + Vector3 front = Vector3.back; + Vector3 back = Vector3.forward; + + EdgeBevel(new Vector3(-hx + r, hy - r, r), new Vector3(hx - r, hy - r, r), front, Vector3.up, r, segs); + EdgeBevel(new Vector3(-hx + r, -hy + r, r), new Vector3(hx - r, -hy + r, r), front, Vector3.down, r, segs); + EdgeBevel(new Vector3(hx - r, -hy + r, r), new Vector3(hx - r, hy - r, r), front, Vector3.right, r, segs); + EdgeBevel(new Vector3(-hx + r, -hy + r, r), new Vector3(-hx + r, hy - r, r), front, Vector3.left, r, segs); + + EdgeBevel(new Vector3(-hx + r, hy - r, d - r), new Vector3(hx - r, hy - r, d - r), back, Vector3.up, r, segs); + EdgeBevel(new Vector3(-hx + r, -hy + r, d - r), new Vector3(hx - r, -hy + r, d - r), back, Vector3.down, r, segs); + EdgeBevel(new Vector3(hx - r, -hy + r, d - r), new Vector3(hx - r, hy - r, d - r), back, Vector3.right, r, segs); + EdgeBevel(new Vector3(-hx + r, -hy + r, d - r), new Vector3(-hx + r, hy - r, d - r), back, Vector3.left, r, segs); + + for (int sx = -1; sx <= 1; sx += 2) + for (int sy = -1; sy <= 1; sy += 2) + { + var corner = new Vector3(sx * (hx - r), sy * (hy - r), 0f); + EdgeBevel(corner + new Vector3(0f, 0f, r), corner + new Vector3(0f, 0f, d - r), + new Vector3(sx, 0f, 0f), new Vector3(0f, sy, 0f), r, segs); + CornerOctant(corner + new Vector3(0f, 0f, r), sx, sy, -1f, r, segs); + CornerOctant(corner + new Vector3(0f, 0f, d - r), sx, sy, 1f, r, segs); + } + } + + // Quarter-circle sweep from dirA (angle 0) to dirB (angle 90) along the centerline c0->c1. + void EdgeBevel(Vector3 c0, Vector3 c1, Vector3 dirA, Vector3 dirB, float r, int segs) + { + float along = Vector3.Distance(c0, c1); + for (int s = 0; s < segs; s++) + { + float a0 = Mathf.PI * 0.5f * s / segs; + float a1 = Mathf.PI * 0.5f * (s + 1) / segs; + Vector3 d0 = Mathf.Cos(a0) * dirA + Mathf.Sin(a0) * dirB; + Vector3 d1 = Mathf.Cos(a1) * dirA + Mathf.Sin(a1) * dirB; + Quad(SubFrame, + c0 + r * d0, c1 + r * d0, c1 + r * d1, c0 + r * d1, + d0, d0, d1, d1, + new Vector2(0f, r * a0), new Vector2(along, r * a0), + new Vector2(along, r * a1), new Vector2(0f, r * a1)); + } + } + + // Eighth-sphere cap where two edge bevels and a vertical corner bevel meet. + void CornerOctant(Vector3 c, float sx, float sy, float sz, float r, int segs) + { + Vector3 Dir(float alpha, float beta) => new Vector3( + sx * Mathf.Sin(alpha) * Mathf.Cos(beta), + sy * Mathf.Sin(alpha) * Mathf.Sin(beta), + sz * Mathf.Cos(alpha)); + + for (int ia = 0; ia < segs; ia++) + for (int ib = 0; ib < segs; ib++) + { + float a0 = Mathf.PI * 0.5f * ia / segs; + float a1 = Mathf.PI * 0.5f * (ia + 1) / segs; + float b0 = Mathf.PI * 0.5f * ib / segs; + float b1 = Mathf.PI * 0.5f * (ib + 1) / segs; + Vector3 d00 = Dir(a0, b0); + Vector3 d10 = Dir(a1, b0); + Vector3 d11 = Dir(a1, b1); + Vector3 d01 = Dir(a0, b1); + Quad(SubFrame, + c + r * d00, c + r * d10, c + r * d11, c + r * d01, + d00, d10, d11, d01, + new Vector2(r * b0, r * a0), new Vector2(r * b0, r * a1), + new Vector2(r * b1, r * a1), new Vector2(r * b1, r * a0)); + } + } + + void Quad(int sub, Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector3 n, + Vector2 uva, Vector2 uvb, Vector2 uvc, Vector2 uvd) + => Quad(sub, a, b, c, d, n, n, n, n, uva, uvb, uvc, uvd); + + // Corners a..d must form a simple loop; winding is derived from the normals, so callers never + // have to reason about triangle order. + void Quad(int sub, Vector3 a, Vector3 b, Vector3 c, Vector3 d, + Vector3 na, Vector3 nb, Vector3 nc, Vector3 nd, + Vector2 uva, Vector2 uvb, Vector2 uvc, Vector2 uvd) + { + int i = _verts.Count; + _verts.Add(a); _verts.Add(b); _verts.Add(c); _verts.Add(d); + _normals.Add(na); _normals.Add(nb); _normals.Add(nc); _normals.Add(nd); + _uvs.Add(uva); _uvs.Add(uvb); _uvs.Add(uvc); _uvs.Add(uvd); + + Vector3 winding = Vector3.Cross(b - a, c - a); + if (winding.sqrMagnitude < 1e-12f) winding = Vector3.Cross(c - a, d - a); + var tris = _tris[sub]; + if (Vector3.Dot(winding, na + nb + nc + nd) >= 0f) + { + tris.Add(i); tris.Add(i + 1); tris.Add(i + 2); + tris.Add(i); tris.Add(i + 2); tris.Add(i + 3); + } + else + { + tris.Add(i); tris.Add(i + 2); tris.Add(i + 1); + tris.Add(i); tris.Add(i + 3); tris.Add(i + 2); + } + } + } +} diff --git a/Assets/Scripts/Components/PictureFrameBuilder.cs.meta b/Assets/Scripts/Components/PictureFrameBuilder.cs.meta new file mode 100644 index 0000000..c2b0ae0 --- /dev/null +++ b/Assets/Scripts/Components/PictureFrameBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 86a75d0253697424fb4b506c394f13bc \ No newline at end of file