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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 86a75d0253697424fb4b506c394f13bc
|
||||
Reference in new issue
Block a user