Files
chameleon/Assets/Scripts/Components/PictureFrameBuilder.cs
T
adminandClaude Fable 5 5dbdc0e6b2 Add Picture Texture field to PictureFrameBuilder
Assigned texture drives the picture material BaseMap via a per-frame
material instance (asset untouched, frames sharing a material stay
independent) and takes priority for aspect resolution. New default
picture material Frame Picture.mat wired in the script defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:53:54 +02:00

563 lines
29 KiB
C#

using System.Collections.Generic;
using UnityEngine;
namespace Chameleon.Components
{
// 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. An assigned
// PictureTexture becomes the picture material's base map via a per-frame DontSave material
// instance (the shared material asset is never modified) and drives the aspect ratio.
//
// 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. 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
{
const int SubPicture = 0;
const int SubInner = 1;
const int SubFrame = 2;
const int SubBack = 3;
[Header("Size")]
[Tooltip("Length of the frame's longer side in meters; the shorter side follows the picture's aspect ratio.")]
[SerializeField, Min(0.01f)] float _size = 1f;
// Auto-resolved in the editor from the picture material's base map texture (longer side = 1,
// 1:1 when no texture is found) and serialized, because the source image file/importer is not
// available in a build. Manual edits are overwritten on the next rebuild.
[Tooltip("Width : height factors derived from the picture texture (auto-set in the editor, longer side = 1).")]
[SerializeField] Vector2 _aspect = Vector2.one;
[Header("Frame")]
[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.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("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 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;
[Tooltip("Photo for this frame: applied as the picture material's base map via a per-frame material instance (the material asset itself is never modified) and drives the aspect ratio. Empty = the material's own base map is shown.")]
[SerializeField] Texture _pictureTexture;
[Header("Materials")]
[SerializeField] Material _pictureMaterial;
[SerializeField] Material _innerMaterial;
[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>();
[SerializeField, HideInInspector] Transform _colliderRoot;
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;
Material _pictureMaterialInstance;
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()
{
_rebuildQueued = true;
#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 (_pictureMaterialInstance)
{
DestroyOwned(_pictureMaterialInstance);
_pictureMaterialInstance = null;
}
if (!_mesh) return;
if (Application.isPlaying) Destroy(_mesh);
else DestroyImmediate(_mesh);
}
[ContextMenu("Rebuild")]
public void Rebuild()
{
var filter = GetComponent<MeshFilter>();
EnsureMesh(filter);
#if UNITY_EDITOR
ResolveAspect();
#endif
// 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 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;
// 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();
_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 + 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 (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);
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();
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);
// 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)
{
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) 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(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();
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 picture = ResolvePictureMaterial();
var meshRenderer = GetComponent<MeshRenderer>();
var current = meshRenderer.sharedMaterials;
bool dirty = current.Length != 4
|| current[0] != picture || current[1] != _innerMaterial
|| current[2] != _frameMaterial || current[3] != _backMaterial;
if (dirty)
meshRenderer.sharedMaterials = new[] { picture, _innerMaterial, _frameMaterial, _backMaterial };
}
// With a picture texture assigned, the picture slot uses a per-frame DontSave material
// instance whose base map is that texture - many frames can share ONE picture material asset
// with different photos, and the asset is never mutated. Properties are re-copied from the
// source material on every rebuild, so asset edits keep showing up on the instance. Like the
// mesh, the instance is never saved and is recreated in OnEnable after scene load.
Material ResolvePictureMaterial()
{
if (!_pictureTexture || !_pictureMaterial)
{
if (_pictureMaterialInstance)
{
DestroyOwned(_pictureMaterialInstance);
_pictureMaterialInstance = null;
}
return _pictureMaterial;
}
if (!_pictureMaterialInstance)
{
_pictureMaterialInstance = new Material(_pictureMaterial) { hideFlags = HideFlags.DontSave };
}
else
{
if (_pictureMaterialInstance.shader != _pictureMaterial.shader)
_pictureMaterialInstance.shader = _pictureMaterial.shader;
_pictureMaterialInstance.CopyPropertiesFromMaterial(_pictureMaterial);
}
_pictureMaterialInstance.name = _pictureMaterial.name + " (Picture)";
if (_pictureMaterialInstance.HasProperty("_BaseMap"))
_pictureMaterialInstance.SetTexture("_BaseMap", _pictureTexture);
else
_pictureMaterialInstance.mainTexture = _pictureTexture;
return _pictureMaterialInstance;
}
#if UNITY_EDITOR
// Importer API for the source file's pixel size (public in Unity 6, internal in older
// versions - hence reflection; null-fallback is the imported texture size).
static readonly System.Reflection.MethodInfo s_sourceSize = typeof(UnityEditor.TextureImporter)
.GetMethod("GetSourceTextureWidthAndHeight",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance);
// Editor-only: derive _aspect (longer side = 1) from the assigned picture texture (falling
// back to the picture material's base map) and serialize it - the source image file is not
// available in a build. 1:1 whenever the whole chain breaks.
void ResolveAspect()
{
var aspect = Vector2.one;
Texture tex = _pictureTexture;
if (!tex && _pictureMaterial)
{
if (_pictureMaterial.HasProperty("_BaseMap")) tex = _pictureMaterial.GetTexture("_BaseMap");
if (!tex) tex = _pictureMaterial.mainTexture;
}
if (tex)
{
// Prefer the source file's pixel size: importer NPOT/max-size scaling can distort the
// imported texture's proportions (e.g. 4:3 photo -> power-of-two texture).
int w = tex.width;
int h = tex.height;
string path = UnityEditor.AssetDatabase.GetAssetPath(tex);
if (s_sourceSize != null && !string.IsNullOrEmpty(path)
&& UnityEditor.AssetImporter.GetAtPath(path) is UnityEditor.TextureImporter importer)
{
object[] size = { 0, 0 };
s_sourceSize.Invoke(importer, size);
if ((int)size[0] > 0 && (int)size[1] > 0)
{
w = (int)size[0];
h = (int)size[1];
}
}
if (w > 0 && h > 0)
aspect = w >= h ? new Vector2(1f, (float)h / w) : new Vector2((float)w / h, 1f);
}
if ((aspect - _aspect).sqrMagnitude < 1e-12f) return;
_aspect = aspect;
UnityEditor.EditorUtility.SetDirty(this);
}
#endif
// 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);
}
}
}
}