diff --git a/Assets/Scripts/DraggablePanel.cs b/Assets/Scripts/DraggablePanel.cs
new file mode 100644
index 0000000..0fde83e
--- /dev/null
+++ b/Assets/Scripts/DraggablePanel.cs
@@ -0,0 +1,109 @@
+using UnityEngine;
+using UnityEngine.UI;
+using UnityEngine.EventSystems;
+
+///
+/// Makes a UI panel draggable. Attach to the panel itself: it follows the pointer from any of the panel's
+/// non-interactive surface (title bar, padding, labels, background) but ignores presses that begin on a
+/// widget - a (toggle, slider, ...) or a (the
+/// colour-picker field) - so those keep their own click/drag behaviour. It keeps a slice of the panel on
+/// screen and raises it above its siblings while interacting.
+///
+/// Works under any canvas scale (it tracks the pointer in the drag area's local space and applies the
+/// movement delta) and under any anchor/pivot (the on-screen clamp is derived from the panel's real
+/// corners in its parent, not from a fixed top-left assumption).
+///
+[RequireComponent(typeof(RectTransform))]
+public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHandler, IDragHandler
+{
+ [Tooltip("Panel moved by the drag. Empty = this object's RectTransform.")]
+ public RectTransform target;
+
+ [Tooltip("Panel pixels (canvas units) kept on screen on every edge so it can never be lost.")]
+ public float keepVisible = 48f;
+
+ private RectTransform _area; // the panel's parent; pointer positions are tracked in its local space
+ private Vector2 _grabOffset;
+ private bool _dragging;
+
+ private void Awake() => Resolve();
+
+ private void Resolve()
+ {
+ if (!target) target = transform as RectTransform;
+ if (target) _area = target.parent as RectTransform;
+ }
+
+ public void OnPointerDown(PointerEventData eventData)
+ {
+ // Raise the panel when pressing its own surface. (A press that lands on a widget is consumed by
+ // that widget's own handler and never reaches us, so this rarely needs the guard - but it is cheap.)
+ if (!StartedOnWidget(eventData)) BringToFront();
+ }
+
+ public void OnBeginDrag(PointerEventData eventData)
+ {
+ _dragging = false;
+ if (!target) Resolve();
+ if (!target || !_area) return;
+ if (StartedOnWidget(eventData)) return; // a toggle/slider/picker owns this gesture, not the panel
+
+ _dragging = true;
+ BringToFront();
+ // Capture grab offset; the constant frame difference between the area pivot and the panel anchor
+ // cancels out because we only ever apply the pointer delta on top of this offset.
+ if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
+ _area, eventData.position, eventData.pressEventCamera, out var local))
+ _grabOffset = target.anchoredPosition - local;
+ }
+
+ public void OnDrag(PointerEventData eventData)
+ {
+ if (!_dragging || !target || !_area) return;
+ if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
+ _area, eventData.position, eventData.pressEventCamera, out var local))
+ return;
+ target.anchoredPosition = Clamp(local + _grabOffset);
+ }
+
+ // True when the press started on an interactive widget inside the panel (which then owns the gesture).
+ private bool StartedOnWidget(PointerEventData eventData)
+ {
+ var hit = eventData.pointerPressRaycast.gameObject;
+ for (var t = hit ? hit.transform : null; t && t != target; t = t.parent)
+ if (t.GetComponent() || t.GetComponent())
+ return true;
+ return false;
+ }
+
+ private void BringToFront()
+ {
+ if (target) target.SetAsLastSibling();
+ }
+
+ // Anchor/pivot-agnostic clamp: keep at least `keepVisible` px of the panel inside the parent on every
+ // edge, computed from the panel's real corners so it is correct wherever the panel is anchored.
+ private Vector2 Clamp(Vector2 pos)
+ {
+ if (!_area) return pos;
+ Rect area = _area.rect;
+ Rect size = target.rect;
+ Vector2 pivot = target.pivot;
+
+ // Anchor reference point in the area's local space (panels are point-anchored: anchorMin == anchorMax).
+ Vector2 anchorRef = new Vector2(
+ area.xMin + target.anchorMin.x * area.width,
+ area.yMin + target.anchorMin.y * area.height);
+ Vector2 pivotToCorner = new Vector2(pivot.x * size.width, pivot.y * size.height);
+
+ // Bottom-left corner of the panel for this anchoredPosition.
+ Vector2 corner = anchorRef + pos - pivotToCorner;
+
+ float keepX = Mathf.Min(keepVisible, size.width);
+ float keepY = Mathf.Min(keepVisible, size.height);
+ corner.x = Mathf.Clamp(corner.x, area.xMin + keepX - size.width, area.xMax - keepX);
+ corner.y = Mathf.Clamp(corner.y, area.yMin + keepY - size.height, area.yMax - keepY);
+
+ return corner + pivotToCorner - anchorRef;
+ }
+}
diff --git a/Assets/Scripts/DraggablePanel.cs.meta b/Assets/Scripts/DraggablePanel.cs.meta
new file mode 100644
index 0000000..77094ba
--- /dev/null
+++ b/Assets/Scripts/DraggablePanel.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 14b82ffde5db5c547b5f454ac2791815
\ No newline at end of file
diff --git a/Assets/Scripts/PaintHud.cs b/Assets/Scripts/PaintHud.cs
index 21a47f0..44f73d5 100644
--- a/Assets/Scripts/PaintHud.cs
+++ b/Assets/Scripts/PaintHud.cs
@@ -13,7 +13,7 @@ using UnityEngine.InputSystem.UI;
///
/// The layout is a transparent root holding one independently positioned sub-panel per channel
/// (Albedo, Metallic+Smoothness, Occlusion, Emission) plus a global Brush card, each with a title
-/// bar that doubles as a drag handle. The layout comes from one of two sources:
+/// bar and draggable from any of its non-widget surface. The layout comes from one of two sources:
/// - an authored prefab assigned to (instantiated, then bound), or
/// - generated in code (the fallback when no prefab is set).
/// Either way the *bindings* (listeners, picker gradient sprites, painter values) live in code
@@ -55,6 +55,12 @@ public class PaintHud : MonoBehaviour
private const string SliderEmissionIntensity = "Slider_EmissionIntensity";
private const string SliderBrushSize = "Slider_BrushSize";
+ // Panels made draggable at runtime - grabbable from any non-widget surface (both prefab and code-gen paths).
+ private static readonly string[] DraggablePanels =
+ {
+ PanelAlbedo, PanelMetallicSmoothness, PanelOcclusion, PanelEmission, PanelBrushSize
+ };
+
private static readonly Color PanelColor = new Color(0.09f, 0.09f, 0.11f, 0.9f);
private static readonly Color HeaderColor = new Color(0.18f, 0.18f, 0.22f, 1f);
private static readonly Color Accent = new Color(0.40f, 0.75f, 1f, 1f);
@@ -94,10 +100,6 @@ public class PaintHud : MonoBehaviour
go.name = layoutPrefab.name; // strip the "(Clone)" suffix
_panel = go;
}
- else
- {
- _panel = BuildPanelStructure(transform);
- }
WirePanel(_panel.transform);
@@ -117,19 +119,6 @@ public class PaintHud : MonoBehaviour
return f;
}
-#if UNITY_EDITOR
- ///
- /// Editor-only: builds a fresh, unbound panel subtree (stable widget names, no listeners and
- /// no runtime gradient sprites) for saving as a layout prefab. The caller reparents/saves the
- /// returned object and destroys it. The wiring is applied at runtime by .
- ///
- public GameObject CreatePanelForBake()
- {
- _font = LoadFont();
- return BuildPanelStructure(transform);
- }
-#endif
-
// --- Canvas ---
private void EnsureCanvas()
@@ -143,7 +132,7 @@ public class PaintHud : MonoBehaviour
if (!scaler) scaler = gameObject.AddComponent();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f);
- scaler.matchWidthOrHeight = 0.5f;
+ scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
if (!GetComponent()) gameObject.AddComponent();
}
@@ -161,216 +150,6 @@ public class PaintHud : MonoBehaviour
#endif
}
- // --- Structure (layout only; matches the baked prefab) ---
-
- private GameObject BuildPanelStructure(Transform parent)
- {
- // Root: a transparent, full-canvas container (no image -> never blocks raycasts). Each channel
- // lives in its own independently positioned sub-panel, so panels can be placed freely in the
- // prefab now and dragged individually at runtime in a later step.
- var root = NewRect(RootName, parent);
- root.anchorMin = Vector2.zero;
- root.anchorMax = Vector2.one;
- root.offsetMin = Vector2.zero;
- root.offsetMax = Vector2.zero;
-
- const float w = 280f;
-
- // Albedo channel.
- var albedo = NewSubPanel(root, PanelAlbedo, new Vector2(16f, -16f), w, "Albedo");
- AddToggleStructure(albedo, ToggleAlbedo, "Paint");
- AddColorPickerStructure(albedo, PickerAlbedo);
-
- // Metallic + Smoothness channel.
- var ms = NewSubPanel(root, PanelMetallicSmoothness, new Vector2(16f, -262f), w, "Metallic + Smoothness");
- AddToggleStructure(ms, ToggleMetallicSmoothness, "Paint");
- AddSliderStructure(ms, SliderMetallic, "Metallic", 0f, 1f, false);
- AddSliderStructure(ms, SliderSmoothness, "Smoothness", 0f, 1f, false);
-
- // Occlusion channel.
- var occ = NewSubPanel(root, PanelOcclusion, new Vector2(16f, -452f), w, "Occlusion");
- AddToggleStructure(occ, ToggleOcclusion, "Paint");
- AddSliderStructure(occ, SliderOcclusion, "Occlusion", 0f, 1f, false);
-
- // Emission channel.
- var emission = NewSubPanel(root, PanelEmission, new Vector2(312f, -16f), w, "Emission");
- AddToggleStructure(emission, ToggleEmission, "Paint");
- AddColorPickerStructure(emission, PickerEmission);
- AddSliderStructure(emission, SliderEmissionIntensity, "Emission Intensity", 0f, 8f, false);
-
- // Brush size (global, not a paint channel).
- var brush = NewSubPanel(root, PanelBrushSize, new Vector2(312f, -286f), w, "Brush");
- AddSliderStructure(brush, SliderBrushSize, "Brush Size", 1f, 256f, true);
-
- return root.gameObject;
- }
-
- /// Builds one self-contained, independently positioned channel card with a title bar.
- private RectTransform NewSubPanel(RectTransform root, string name, Vector2 pos, float width, string title)
- {
- var panel = NewRect(name, root);
- panel.anchorMin = panel.anchorMax = new Vector2(0f, 1f);
- panel.pivot = new Vector2(0f, 1f);
- panel.anchoredPosition = pos;
- panel.sizeDelta = new Vector2(width, 0f); // width fixed; height from the ContentSizeFitter
- AddImage(panel.gameObject, PanelColor);
-
- var vlg = panel.gameObject.AddComponent();
- vlg.padding = new RectOffset(10, 10, 10, 10);
- vlg.spacing = 8f;
- vlg.childAlignment = TextAnchor.UpperLeft;
- vlg.childControlWidth = true;
- vlg.childControlHeight = true;
- vlg.childForceExpandWidth = true;
- vlg.childForceExpandHeight = false;
-
- var fitter = panel.gameObject.AddComponent();
- fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
-
- AddPanelHeader(panel, title);
- return panel;
- }
-
- private void AddPanelHeader(RectTransform panel, string title)
- {
- // Title bar. Its image is a raycast target so it can double as the drag handle in a later step.
- var rt = NewRect("Header", panel);
- var le = rt.gameObject.AddComponent();
- le.minHeight = le.preferredHeight = 24f;
- AddImage(rt.gameObject, HeaderColor);
-
- var lbl = NewRect("Label", rt);
- lbl.anchorMin = Vector2.zero;
- lbl.anchorMax = Vector2.one;
- lbl.offsetMin = new Vector2(8f, 0f);
- lbl.offsetMax = new Vector2(-8f, 0f);
- var t = MakeText(lbl.gameObject, title, 13, FontStyle.Bold, new Color(0.9f, 0.9f, 0.95f, 1f), TextAnchor.MiddleLeft);
- t.raycastTarget = false;
- }
-
- private void AddToggleStructure(RectTransform panel, string name, string label)
- {
- var rt = NewRect(name, panel);
- var le = rt.gameObject.AddComponent();
- le.minHeight = le.preferredHeight = 22f;
-
- var toggle = rt.gameObject.AddComponent();
-
- var box = NewRect("Box", rt);
- box.anchorMin = box.anchorMax = new Vector2(0f, 0.5f);
- box.pivot = new Vector2(0f, 0.5f);
- box.anchoredPosition = Vector2.zero;
- box.sizeDelta = new Vector2(18f, 18f);
- var boxImg = AddImage(box.gameObject, BoxColor);
-
- var check = NewRect("Check", box);
- check.anchorMin = Vector2.zero;
- check.anchorMax = Vector2.one;
- check.offsetMin = new Vector2(3f, 3f);
- check.offsetMax = new Vector2(-3f, -3f);
- var checkImg = AddImage(check.gameObject, Accent);
- checkImg.raycastTarget = false;
-
- var lbl = NewRect("Label", rt);
- lbl.anchorMin = Vector2.zero;
- lbl.anchorMax = Vector2.one;
- lbl.offsetMin = new Vector2(24f, 0f);
- lbl.offsetMax = Vector2.zero;
- var t = MakeText(lbl.gameObject, label, 14, FontStyle.Normal, Color.white, TextAnchor.MiddleLeft);
- t.raycastTarget = false;
-
- toggle.targetGraphic = boxImg;
- toggle.graphic = checkImg;
- }
-
- private void AddSliderStructure(RectTransform panel, string name, string label, float min, float max, bool whole)
- {
- var rt = NewRect(name, panel);
- var le = rt.gameObject.AddComponent();
- le.minHeight = le.preferredHeight = 34f;
-
- // Caption row (label left, value right), pinned to the top of the cell.
- var lbl = NewRect("Label", rt);
- lbl.anchorMin = new Vector2(0f, 1f);
- lbl.anchorMax = new Vector2(1f, 1f);
- lbl.pivot = new Vector2(0f, 1f);
- lbl.offsetMin = new Vector2(0f, -16f);
- lbl.offsetMax = new Vector2(-48f, 0f);
- var lt = MakeText(lbl.gameObject, label, 13, FontStyle.Normal, new Color(0.8f, 0.8f, 0.85f, 1f), TextAnchor.MiddleLeft);
- lt.raycastTarget = false;
-
- var valRt = NewRect("Value", rt);
- valRt.anchorMin = new Vector2(1f, 1f);
- valRt.anchorMax = new Vector2(1f, 1f);
- valRt.pivot = new Vector2(1f, 1f);
- valRt.anchoredPosition = Vector2.zero;
- valRt.sizeDelta = new Vector2(56f, 16f);
- var vt = MakeText(valRt.gameObject, "", 13, FontStyle.Normal, Color.white, TextAnchor.MiddleRight);
- vt.raycastTarget = false;
-
- // Track, pinned to the bottom of the cell.
- var track = NewRect("Track", rt);
- track.anchorMin = new Vector2(0f, 0f);
- track.anchorMax = new Vector2(1f, 0f);
- track.pivot = new Vector2(0.5f, 0f);
- track.anchoredPosition = Vector2.zero;
- track.sizeDelta = new Vector2(0f, 16f);
- var slider = track.gameObject.AddComponent();
-
- var bg = NewRect("Background", track);
- bg.anchorMin = new Vector2(0f, 0.5f);
- bg.anchorMax = new Vector2(1f, 0.5f);
- bg.pivot = new Vector2(0.5f, 0.5f);
- bg.anchoredPosition = Vector2.zero;
- bg.sizeDelta = new Vector2(0f, 5f);
- AddImage(bg.gameObject, BoxColor); // raycast target so clicking the bar jumps the value
-
- var fillArea = NewRect("Fill Area", track);
- fillArea.anchorMin = new Vector2(0f, 0.5f);
- fillArea.anchorMax = new Vector2(1f, 0.5f);
- fillArea.pivot = new Vector2(0.5f, 0.5f);
- fillArea.anchoredPosition = Vector2.zero;
- fillArea.sizeDelta = new Vector2(-16f, 5f);
- var fill = NewRect("Fill", fillArea);
- fill.anchorMin = Vector2.zero;
- fill.anchorMax = Vector2.one;
- fill.offsetMin = Vector2.zero;
- fill.offsetMax = Vector2.zero;
- var fillImg = AddImage(fill.gameObject, Accent);
- fillImg.raycastTarget = false;
-
- var handleArea = NewRect("Handle Slide Area", track);
- handleArea.anchorMin = Vector2.zero;
- handleArea.anchorMax = Vector2.one;
- handleArea.offsetMin = new Vector2(8f, 0f);
- handleArea.offsetMax = new Vector2(-8f, 0f);
- var handle = NewRect("Handle", handleArea);
- handle.sizeDelta = new Vector2(14f, 0f);
- var handleImg = AddImage(handle.gameObject, Color.white);
-
- slider.fillRect = fill;
- slider.handleRect = handle;
- slider.targetGraphic = handleImg;
- slider.direction = Slider.Direction.LeftToRight;
- slider.minValue = min;
- slider.maxValue = max;
- slider.wholeNumbers = whole; // serialized: the binder reads it back for value formatting
- }
-
- private HsvColorPicker AddColorPickerStructure(RectTransform panel, string name)
- {
- const float square = 120f;
- const float hueH = 14f;
-
- var rt = NewRect(name, panel);
- var le = rt.gameObject.AddComponent();
- le.minHeight = le.preferredHeight = square + 6f + hueH;
-
- var picker = rt.gameObject.AddComponent();
- picker.Build(square, hueH); // structure only; sprites + hooks are applied by Rebind in WirePanel
- return picker;
- }
-
// --- Wiring (behaviour; shared by the code-gen and prefab paths) ---
private void WirePanel(Transform panel)
@@ -394,6 +173,23 @@ public class PaintHud : MonoBehaviour
WireSlider(panel, SliderEmissionIntensity, painter ? painter.emissionIntensity : 1f, v => { if (painter) painter.emissionIntensity = v; });
WireSlider(panel, SliderBrushSize, painter ? painter.brushSize : 16f, v => { if (painter) painter.brushSize = Mathf.RoundToInt(v); });
+
+ foreach (var name in DraggablePanels)
+ MakeDraggable(panel, name);
+ }
+
+ ///
+ /// Attaches (idempotently) a to the whole panel so it can be dragged
+ /// from any of its non-interactive surface (header, padding, labels, background); the component
+ /// ignores presses that start on a widget (toggle, slider, colour picker).
+ ///
+ private static void MakeDraggable(Transform root, string panelName)
+ {
+ var panel = FindDeep(root, panelName) as RectTransform;
+ if (!panel) return;
+ var drag = panel.GetComponent();
+ if (!drag) drag = panel.gameObject.AddComponent();
+ drag.target = panel;
}
private void EnsureFonts(Transform panel)
@@ -417,7 +213,7 @@ public class PaintHud : MonoBehaviour
var slider = cell.GetComponentInChildren();
if (!slider) return;
var valueRt = cell.Find("Value");
- var valueText = valueRt ? valueRt.GetComponent() : null;
+ var valueText = valueRt ? valueRt.GetComponent() : null;
bool whole = slider.wholeNumbers;
slider.value = value;
@@ -457,20 +253,6 @@ public class PaintHud : MonoBehaviour
return null;
}
- private Text MakeText(GameObject go, string text, int size, FontStyle style, Color color, TextAnchor anchor)
- {
- var t = go.AddComponent();
- t.font = _font;
- t.fontSize = size;
- t.fontStyle = style;
- t.color = color;
- t.alignment = anchor;
- t.text = text;
- t.horizontalOverflow = HorizontalWrapMode.Overflow;
- t.verticalOverflow = VerticalWrapMode.Truncate;
- return t;
- }
-
// --- Static UI factory (shared with HsvColorPicker) ---
public static RectTransform NewRect(string name, Transform parent)