Make picture frame properties relative and fix picture aspect ratio

Frame thickness, corner radius, padding, inner depth and picture depth
are now normalized factors (scale with Size, no dead clamp zones).
Picture aspect is computed inside-out so padding and frame thickness
no longer distort the image. New Frame Inner.mat as inner default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-09 21:32:19 +02:00
1 parent 07aef0c7b2
commit 6a15a68a2e
4 files changed
+244 -35

No files matched your search

@@ -3,11 +3,13 @@ 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 Size (width/height follow the
// picture texture's aspect ratio), NOT via transform scale, so the frame profile never stretches
// (9-slice behaviour). Four submeshes with their own
// Procedural picture frame, generated live in the editor (and at runtime) from the inspector
// parameters. Sizing is inside-out: the picture keeps the texture's aspect ratio EXACTLY and the
// outer frame grows around it by padding + thickness (Size = longer outer side) - insetting a
// uniform border from an aspect-scaled outer rect would distort the picture. All shape parameters
// are relative fractions (thickness/padding of the picture's longer side, radius of the maximum
// possible rounding, depths of the frame depth), so a frame keeps its proportions when resized via
// Size - resize via Size, NOT via transform scale. 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.
//
@@ -17,7 +19,11 @@ namespace Chameleon.Components
//
// 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.
// _colliders are touched - manually added ones are left alone. The boxes live on a dedicated
// "Colliders" child: adding/removing a component on the inspected GameObject itself would rebuild
// its inspector mid-drag and release the slider being held (Unity 6's UI Toolkit inspector never
// sets GUIUtility.hotControl, so the mouse state cannot be polled) - on a child they can appear
// and disappear live without ever disturbing the editing.
[ExecuteAlways]
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public sealed class PictureFrameBuilder : MonoBehaviour
@@ -37,22 +43,22 @@ namespace Chameleon.Components
[SerializeField] Vector2 _aspect = Vector2.one;
[Header("Frame")]
[Tooltip("Border width of the frame profile in the picture plane.")]
[SerializeField, Range(0.001f, 1.000f)] float _frameThickness = 0.08f;
[Tooltip("Border width of the frame as a fraction of the picture's longer side.")]
[SerializeField, Range(0f, 1f)] float _frameThickness = 0.05f;
[Tooltip("Depth of the frame, front face to back face.")]
[SerializeField, Range(0.002f, 0.500f)] float _frameDepth = 0.12f;
[Tooltip("Rounding radius of the outer frame edges; 0 = sharp box. Clamped to thickness / half depth.")]
[SerializeField, Range(0.000f, 0.500f)] float _cornerRadius;
[SerializeField, Range(0.002f, 0.500f)] float _frameDepth = 0.05f;
[Tooltip("Rounding of the outer frame edges relative to the maximum possible radius (thickness / half depth); 0 = sharp box, 1 = fully rounded.")]
[SerializeField, Range(0f, 1f)] float _cornerRadius = 0.2f;
[Tooltip("Arc segments of the edge rounding.")]
[SerializeField, Range(1, 8)] int _bevelSegments = 4;
[Tooltip("How deep behind the front face the inner floor (padding ring) sits.")]
[SerializeField, Range(0.000f, 0.498f)] float _frameInnerDepth = 0.06f;
[Tooltip("Inner floor relative to the frame depth: -1 = fully sunken (on the back plate), 0 = flush with the front face, 1 = a full frame depth in front.")]
[SerializeField, Range(-1f, 1f)] float _frameInnerDepth = -0.1f;
[Header("Picture")]
[Tooltip("Gap between the frame's inner edge and the picture (visible inner-material ring).")]
[SerializeField, Range(0.000f, 1.000f)] 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, Range(-0.500f, 0.500f)] float _pictureDepth;
[Tooltip("Gap between the frame's inner edge and the picture as a fraction of the picture's longer side.")]
[SerializeField, Range(0f, 1f)] float _padding = 0.01f;
[Tooltip("Picture plane: -1 = fully inside (on the back plate), 0 = flush with the inner floor, 1 = a full frame depth in front of the frame.")]
[SerializeField, Range(-1f, 1f)] float _pictureDepth = 0.1f;
[Header("Materials")]
[SerializeField] Material _pictureMaterial;
@@ -65,6 +71,7 @@ namespace Chameleon.Components
[SerializeField] bool _generateColliders = true;
// Ownership list so rebuilds never touch colliders the user added by hand.
[SerializeField, HideInInspector] List<BoxCollider> _colliders = new List<BoxCollider>();
[SerializeField, HideInInspector] Transform _colliderRoot;
readonly List<Vector3> _verts = new List<Vector3>();
readonly List<Vector3> _normals = new List<Vector3>();
@@ -117,19 +124,30 @@ namespace Chameleon.Components
#if UNITY_EDITOR
ResolveAspect();
#endif
float w = Mathf.Max(_size * _aspect.x, 0.01f);
float h = Mathf.Max(_size * _aspect.y, 0.01f);
float t = Mathf.Clamp(_frameThickness, 0.001f, Mathf.Min(w, h) * 0.5f - 0.001f);
// Inside-out sizing: the picture keeps the texture aspect exactly; thickness and padding
// are fractions of the picture's longer side, and Size stays the longer OUTER side:
// size = pLong + 2*(t + pad) = pLong * (1 + thickness + padding). No clamp dead zones -
// every 0..1 combination yields valid geometry.
float d = Mathf.Max(_frameDepth, 0.002f);
float r = Mathf.Min(_cornerRadius, Mathf.Min(t, d * 0.5f) - 0.0005f);
float pLong = Mathf.Max(_size, 0.01f) / (1f + _frameThickness + _padding);
float t = Mathf.Max(_frameThickness * pLong * 0.5f, 0.001f);
float pad = _padding * pLong * 0.5f;
float pw = Mathf.Max(pLong * _aspect.x, 0.001f);
float ph = Mathf.Max(pLong * _aspect.y, 0.001f);
float ow = pw + 2f * pad;
float oh = ph + 2f * pad;
float w = ow + 2f * t;
float h = oh + 2f * t;
float r = _cornerRadius * Mathf.Max(Mathf.Min(t, d * 0.5f) - 0.0005f, 0f);
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);
// Depths are fractions of the frame depth. Inner floor: -1 = back plate .. +1 = a full
// frame depth in front of the face. Picture: interpolates from the back plate (-1) over
// the inner floor (0) to a full frame depth in front (+1) - endpoints are absolute so
// the extremes always mean "all the way in/out" regardless of where the floor sits.
float id = -_frameInnerDepth * d;
float pz = _pictureDepth < 0f
? Mathf.Lerp(id, d, -_pictureDepth)
: Mathf.Lerp(id, -d, _pictureDepth);
_verts.Clear();
_normals.Clear();
@@ -148,9 +166,11 @@ namespace Chameleon.Components
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.
// Inner cavity: padding ring around the picture + walls between the front face and the
// inner floor (inward recess when sunken, outward block sides when it protrudes).
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);
if (Mathf.Abs(id) > 1e-4f)
PerimeterWalls(SubInner, ow * 0.5f, oh * 0.5f, Mathf.Min(id, 0f), Mathf.Max(id, 0f), id > 0f ? -1f : 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);
@@ -185,6 +205,22 @@ namespace Chameleon.Components
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);
// Migrate colliders older versions created directly on this GameObject: they must move to
// the child root, otherwise every structural change here would rebuild the inspector.
for (int i = _colliders.Count - 1; i >= 0; i--)
{
if (_colliderRoot && _colliders[i].transform == _colliderRoot) continue;
DestroyOwned(_colliders[i]);
_colliders.RemoveAt(i);
}
// The root child is fully owned, so any box that slipped out of the list (undo, aborted
// rebuild) is adopted back instead of leaking as an orphan.
if (_colliderRoot)
foreach (var box in _colliderRoot.GetComponents<BoxCollider>())
if (!_colliders.Contains(box)) _colliders.Add(box);
int count = 0;
if (_generateColliders)
{
@@ -206,20 +242,48 @@ namespace Chameleon.Components
{
var extra = _colliders[i];
_colliders.RemoveAt(i);
if (!extra) continue;
if (Application.isPlaying) Destroy(extra);
else DestroyImmediate(extra);
if (extra) DestroyOwned(extra);
}
// Generation switched off: drop the root too, but only when nothing foreign lives on it.
if (!_generateColliders && _colliderRoot && _colliderRoot.childCount == 0 &&
_colliderRoot.GetComponents<Component>().Length == 1)
{
var go = _colliderRoot.gameObject;
_colliderRoot = null;
DestroyOwned(go);
}
}
void SetBox(int index, Vector3 center, Vector3 size)
{
while (_colliders.Count <= index) _colliders.Add(gameObject.AddComponent<BoxCollider>());
while (_colliders.Count <= index)
_colliders.Add(EnsureColliderRoot().gameObject.AddComponent<BoxCollider>());
var box = _colliders[index];
if (box.center != center) box.center = center;
if (box.size != size) box.size = size;
}
Transform EnsureColliderRoot()
{
if (_colliderRoot) return _colliderRoot;
var existing = transform.Find("Colliders");
if (existing) _colliderRoot = existing;
else
{
var go = new GameObject("Colliders") { layer = gameObject.layer };
go.transform.SetParent(transform, false);
_colliderRoot = go.transform;
}
return _colliderRoot;
}
static void DestroyOwned(Object o)
{
if (Application.isPlaying) Destroy(o);
else DestroyImmediate(o);
}
void EnsureMesh(MeshFilter filter)
{
string meshName = "PictureFrame " + GetInstanceID();