diff --git a/Assets/Materials/Frame Inner.mat b/Assets/Materials/Frame Inner.mat new file mode 100644 index 0000000..00cfe9a --- /dev/null +++ b/Assets/Materials/Frame Inner.mat @@ -0,0 +1,136 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: Frame Inner + m_Shader: {fileID: 4800000, guid: 023c98a32f6cc00478e50479839530c5, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: + RenderType: Opaque + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _SpecGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_Lightmaps: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_LightmapsInd: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_ShadowMasks: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _AddPrecomputedVelocity: 0 + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DetailAlbedoMapScale: 1 + - _DetailNormalMapScale: 1 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _EnvironmentReflections: 1 + - _GlossMapScale: 0 + - _Glossiness: 0 + - _GlossyReflections: 0 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.005 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _Smoothness: 0 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _WorkflowMode: 1 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.8805031, g: 0.8805031, b: 0.8805031, a: 1} + - _Color: {r: 0.8805031, g: 0.8805031, b: 0.8805031, a: 1} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &6905797306856702536 +MonoBehaviour: + m_ObjectHideFlags: 11 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 diff --git a/Assets/Materials/Frame Inner.mat.meta b/Assets/Materials/Frame Inner.mat.meta new file mode 100644 index 0000000..dc52278 --- /dev/null +++ b/Assets/Materials/Frame Inner.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bc0ac45503ad5c44b8b966da0d0a40d3 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Components/PictureFrameBuilder.cs b/Assets/Scripts/Components/PictureFrameBuilder.cs index 20ec461..3db8e1f 100644 --- a/Assets/Scripts/Components/PictureFrameBuilder.cs +++ b/Assets/Scripts/Components/PictureFrameBuilder.cs @@ -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 _colliders = new List(); + [SerializeField, HideInInspector] Transform _colliderRoot; readonly List _verts = new List(); readonly List _normals = new List(); @@ -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()) + 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().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()); + while (_colliders.Count <= index) + _colliders.Add(EnsureColliderRoot().gameObject.AddComponent()); 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(); diff --git a/Assets/Scripts/Components/PictureFrameBuilder.cs.meta b/Assets/Scripts/Components/PictureFrameBuilder.cs.meta index 92231bb..e1f753e 100644 --- a/Assets/Scripts/Components/PictureFrameBuilder.cs.meta +++ b/Assets/Scripts/Components/PictureFrameBuilder.cs.meta @@ -5,9 +5,10 @@ MonoImporter: serializedVersion: 2 defaultReferences: - _pictureMaterial: {instanceID: 0} - - _innerMaterial: {fileID: 2100000, guid: a7794896ef878a64399cca929db5c43c, type: 2} + - _innerMaterial: {fileID: 2100000, guid: bc0ac45503ad5c44b8b966da0d0a40d3, type: 2} - _frameMaterial: {fileID: 2100000, guid: a7794896ef878a64399cca929db5c43c, type: 2} - _backMaterial: {fileID: 2100000, guid: a7794896ef878a64399cca929db5c43c, type: 2} + - _colliderRoot: {instanceID: 0} executionOrder: 0 icon: {instanceID: 0} userData: