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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-09 19:19:59 +02:00
1 parent bdce9ea0c8
commit 210d0adc3e
2 files changed
+336

No files matched your search

@@ -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<Vector3> _verts = new List<Vector3>();
readonly List<Vector3> _normals = new List<Vector3>();
readonly List<Vector2> _uvs = new List<Vector2>();
readonly List<int>[] _tris =
{
new List<int>(), new List<int>(), new List<int>(), new List<int>()
};
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<MeshFilter>();
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<MeshRenderer>();
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);
}
}
}
}