diff --git a/Readme.md b/Readme.md index 8198134..8eb88c6 100644 --- a/Readme.md +++ b/Readme.md @@ -98,7 +98,7 @@ Assets/Darkmatter/ │ ├── Compatibility/ │ │ └── IsExternalInit.cs (C#9 init shim for older runtimes) │ ├── Contracts/ - │ │ ├── Paper/ ← misplaced empty folder — should be Contracts/Features/Paper/ (delete or move) + │ │ ├── Paper/ ← misplaced empty folder — move to Contracts/Features/Paper/ when IPaperSurface lands │ │ └── Services/ │ │ ├── Assets/IAssetProviderService.cs │ │ ├── Audio/IAudioService.cs, ISfxPlayer.cs @@ -175,7 +175,7 @@ Rough landing order for ColorBook scene to be playable: | Path | Role | |---|---| -| `Core/Contracts/Features/Paper/IPaperRig.cs`, `IArtInputBridge.cs` | Paper rig contracts | +| `Core/Contracts/Features/Paper/IPaperSurface.cs` | Paper surface contract (canvas roots) | | `Core/Contracts/Services/Capture/ICaptureService.cs` | Capture service contract | | `Core/Contracts/Services/Gallery/IGalleryService.cs` | Gallery service contract | | `Core/Contracts/Features/Drawing/IDrawingTemplate.cs`, `IDrawingTemplateCatalog.cs` | Drawing template contracts | @@ -190,9 +190,9 @@ Rough landing order for ColorBook scene to be playable: | `Core/Data/Dynamic/Features/Signals/` (DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, ArtworkCapturedSignal, ArtworkSavedSignal) | Cross-feature signal structs | | `Core/Enums/Services/Camera/CameraType.cs` | Add `ArtCamera` enum value to existing file | | `Libs/CommandStack/` (+ `Libs.CommandStack.asmdef`) | Bounded undo/redo | -| `Services/Capture/` (+ `Services.Capture.asmdef`) | `RenderTextureCaptureService` reads `IPaperRig.Surface` | +| `Services/Capture/` (+ `Services.Capture.asmdef`) | `RenderTextureCaptureService` drives the disabled `CaptureCamera` | | `Services/Gallery/` (+ `Services.Gallery.asmdef`) | `FileGalleryService` — PNG + sidecar JSON IO | -| `Features/Paper/` (+ `Features.Paper.asmdef`) | Scene-bound RT rig | +| `Features/Paper/` (+ `Features.Paper.asmdef`) | Scene-bound `PaperSurface` MB + module | | `Features/{MainMenu,DrawingCatalog,ShapeBuilder,Coloring,History,Capture,Progression,ColorBookFlow,ArtBook}/` (+ asmdefs each) | Game features | | `App/LifetimeScopes/{MainMenu,ColorBook,ArtBook}LifetimeScope.cs` | Per-scene scopes | | `App/Boot/AppBoot.cs` | Bootstrap entry point | @@ -283,59 +283,87 @@ Failures show a child-friendly retry screen; never crash. ## 7. Rendering Strategy -**RT-as-paper.** ArtCamera renders the drawing world to an offscreen `RenderTexture`. A Canvas `RawImage` displays that RT. HUD lives on the same Canvas, above the RawImage. The RT *is* the paper — same fixed coordinate system on every device. +**Full Canvas UI.** No `SpriteRenderer`, no `Physics2D`, no offscreen `RenderTexture` for the live view. The paper, slots, pieces, and color regions are all `Image` components on a Screen-Space-Camera canvas. Standard Unity UI eventing (`IPointerDownHandler`, `IDragHandler`) handles all input. ``` -┌──────────────────────────────────────────────────────┐ -│ UICanvas (Screen-Space - Camera, UICamera) │ -│ │ -│ ┌────────────────────────────────────┐ │ -│ │ RawImage (AspectRatioFitter 1:1) │ [HUD] │ -│ │ └─ texture = PaperRig.Surface │ palette │ -│ │ │ undo etc │ -│ │ ArtCamera renders → here │ │ -│ └────────────────────────────────────┘ │ -│ │ -└──────────────────────────────────────────────────────┘ - ▲ - │ rendered offscreen - │ - ArtCamera (orthographicSize fixed, aspect = 1f) - culling mask: Artwork, PaperBackground, Effects - target texture: PaperRig.Surface (2048×2048 ARGB32) +┌──────────────────────────────────────────────────────────┐ +│ PaperCanvas (Screen Space - Camera, UICamera) │ +│ layer: PaperUI │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ PaperPanel (RectTransform, 2048×2048 ref units) │ │ +│ │ ├─ BackgroundImage │ │ +│ │ ├─ SlotsPanel (slot Image outlines) │ │ +│ │ ├─ PiecesPanel (draggable piece Images) │ │ +│ │ └─ RegionsPanel (colorable region Images) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────┐ +│ HUDCanvas (Screen Space - Overlay, OR separate camera) │ +│ layer: HUDUI │ +│ ├─ Palette panel │ +│ ├─ Undo / Redo buttons │ +│ ├─ Capture / Next buttons │ +│ └─ Tray panel (during build phase) │ +└──────────────────────────────────────────────────────────┘ + +CaptureCamera (disabled by default, one-shot Render() on capture) + orthographic, projection cloned from UICamera + cullingMask = PaperUI only + targetTexture = temp RT allocated per capture (2048×2048) ``` ### Cameras -| Camera | Type | Culling Mask | Render Target | Purpose | +| Camera | Render mode | Culling Mask | Render Target | Purpose | |---|---|---|---|---| -| `ArtCamera` | Orthographic, **fixed ortho size**, aspect = 1 | `Artwork`, `PaperBackground`, `Effects` | `PaperRig.Surface` (offscreen RT) | Renders the drawing world. Never sees the screen. | -| `UICamera` | Camera (Screen-Space – Camera) | `UI` | Screen | Displays the paper RawImage + HUD. | +| `UICamera` | Screen-Space - Camera (orthographic) | `PaperUI`, `HUDUI` | Screen | Normal display each frame. | +| `CaptureCamera` | Orthographic, disabled | `PaperUI` only | temp `RenderTexture` | One-shot `Render()` invoked by `ICaptureService.CaptureAsync()`. | + +`CaptureCamera` shares `UICamera`'s position, orthographic size, and clip planes so the captured frame matches what the player sees — minus the HUD because of the culling mask. ### Layers | Layer | Used by | |---|---| -| `Artwork` | Drawing region sprites, shape pieces, paper bg, all in ArtCamera world | -| `Effects` | Particle bursts, sparkles — also in ArtCamera world (so they're captured into the PNG) | -| `UI` | All Canvas elements (RawImage paper + HUD) | +| `PaperUI` | `PaperCanvas` and all of its children (background, slots, pieces, regions, completion FX). Visible in capture. | +| `HUDUI` | `HUDCanvas` and tray panel (palette, undo/redo, capture button, drawing catalog grid, etc.). Excluded from capture. | +| `EventSystem` | Unity's input layer — managed automatically. | -### Why RT-as-paper +### Why full UI | Need | Choice | Why | |---|---|---| -| Per-region tap-to-fill | Sprites + `PolygonCollider2D` in ArtCamera world; tapped via `IArtInputBridge` | Coordinate system is fixed (RT space). One `Physics2D.OverlapPoint` call after screen→art-world conversion. | -| Drag/drop shape pieces | Sprites + Physics2D in art world | Same fixed bounds on every device — no per-aspect tray layout. | -| Capture to PNG | `RT → Texture2D → PNG` | The RT *is* the saved image. No camera state override, no compositing pass, no determinism worries. | -| Multi-resolution support | `AspectRatioFitter (1:1, FitInParent)` on the RawImage | The "fit camera" problem reduces to a single Canvas property. Letterbox/pillarbox = whatever the Canvas around the RawImage looks like. | -| Color palette, buttons | Canvas above the RawImage | Anchors handle aspect ratios. Buttons + ScrollRect free. | -| Drawing catalog grid | Canvas | `GridLayoutGroup` + ScrollRect, async thumbnail loader. | +| Tap-to-paint region | `Image` + `Image.alphaHitTestMinimumThreshold` + `IPointerClickHandler` | Tight alpha-based hit shape per region. No mesh / collider authoring. Tap events route through `EventSystem` natively. | +| Drag/drop shape pieces | `Image` + `IBeginDragHandler` / `IDragHandler` / `IEndDragHandler` | Standard Unity UI drag. Pointer events come in canvas-local coords already. No screen→world math anywhere. | +| Visual transition during drag → snap | `DOAnchorPos`, `DOSizeDelta`, `DOLocalRotate` | All pose is in `RectTransform` units. The "transition" is a tween over canvas-local values — no swap of render context. | +| Capture to PNG | Dedicated `CaptureCamera` with `cullingMask = PaperUI` | One `Render()` call into a temp RT. HUD physically can't appear. | +| Multi-resolution support | `CanvasScaler` on `PaperCanvas` (Scale With Screen Size) | Reference resolution `2048 × 2048`, Match = 1 (height). All `anchoredPosition` units are constant across devices. | +| HUD layout independent of paper | `HUDCanvas` (separate Canvas) | HUD scales/anchors per its own rules without affecting the paper layout. | +| Drawing catalog grid, palette, etc. | Standard UI (`GridLayoutGroup`, `ScrollRect`, `Button`) | Anchors handle aspect ratios. Async thumbnail loader. | ### Multi-resolution rule -The artwork world is **screen-size-independent by construction.** Author every drawing in a fixed 2048×2048 design rect (or 20×20 world units at PPU=100). Pieces, regions, snap radii, slot positions — all expressed in this space and never scaled at runtime. Different screen sizes only change how the *RawImage* is laid out on the Canvas; the contents of the RT stay identical. +The paper content is **canvas-unit-stable.** Author every drawing against a fixed 2048 × 2048 reference resolution. Slot positions, piece sizes, region rects, hit shapes — all expressed in `anchoredPosition` / `sizeDelta` units. `CanvasScaler` on `PaperCanvas` does the screen mapping. -If you need a backdrop (wood/cloth behind the paper), it's a sibling Canvas Image *outside* the RawImage, sized to fill the screen. The RT itself has a transparent or paper-colored background. +`PaperPanel` is anchored center, fixed 2048×2048 (or whatever you pick for the reference). On a wider screen, `CanvasScaler` pillarboxes the panel; on a narrower screen, it letterboxes. The panel's contents never resize relative to each other. + +If you want a backdrop (wood/cloth behind the paper area), it's a sibling `Image` of `PaperPanel` (still on `PaperUI` layer) sized to fill the canvas. The backdrop *is* captured into the PNG by default — set its layer to `HUDUI` if you want it excluded. + +### Tradeoff vs the old RT-paper-rig design + +| Concern | RT-paper-rig (old) | Canvas-only (current) | +|---|---|---| +| Files in `IPaperRig` / `IArtInputBridge` | 2 contracts + ~80 lines of math | gone | +| Input pipeline | `IInputReader` → bridge → `Physics2D.OverlapPoint` | native `EventSystem` (`IPointerDownHandler` etc.) | +| Coloring hit shape | `PolygonCollider2D` from `Sprite.Editor` physics shape | `Image.alphaHitTestMinimumThreshold = 0.5f` on the region sprite | +| Per-frame render passes | 2 (ArtCamera into RT + UICamera draws RawImage) | 1 (UICamera draws everything) | +| Capture | read persistent RT | one-shot `CaptureCamera.Render()` | +| Coordinate gotchas | mismatches between screen / RT / world | none — everything is canvas-local | + +If you ever need world-space effects (particle sparkles that physically explode outside the paper, free-draw brush stroke, pinch zoom on the artwork), revisit the RT approach. For the v1 tap-to-fill + drag-to-snap design, Canvas-only is correct. --- @@ -361,17 +389,22 @@ public interface IDrawingTemplate { public readonly struct ShapePieceDTO { public string PieceId { get; } - public Sprite Sprite { get; } - public Vector2 SlotPosition { get; } - public float SlotRotation { get; } - public float SnapRadius { get; } // generous for toddlers + public Sprite Sprite { get; } // assigned to Image.sprite + public Vector2 SlotAnchoredPosition { get; } // canvas units, relative to SlotsParent + public Vector2 SlotSizeDelta { get; } // canvas units — target size when snapped + public float SlotRotationZ { get; } // degrees, local rotation when snapped + public float SnapRadius { get; } // canvas units; ~80–120 for toddlers + public float PreviewRadius { get; } // canvas units; ~2× snap radius } public readonly struct ColorRegionDTO { public string RegionId { get; } - public Sprite Sprite { get; } // sprite renderer source - public Vector2[] ColliderPath { get; } // polygon collider points - public Color InitialColor { get; } // usually white + public Sprite Sprite { get; } // assigned to Image.sprite + public Vector2 AnchoredPosition { get; } // canvas units, relative to RegionsParent + public Vector2 SizeDelta { get; } // canvas units + public Color InitialColor { get; } // usually white + // Hit shape comes from the sprite alpha — set Image.alphaHitTestMinimumThreshold = 0.5. + // No polygon path needed; sprite import settings ("Read/Write Enabled") provide it. } ``` @@ -394,31 +427,25 @@ public readonly struct PaintCommandDTO { } ``` -### Paper (RT rig + input bridge) +### Paper (canvas surface root) > Contracts live in `Darkmatter.Core.Contracts.Features.Paper`. Files at `Core/Contracts/Features/Paper/`. ```csharp namespace Darkmatter.Core.Contracts.Features.Paper; -public interface IPaperRig { - Camera ArtCamera { get; } // offscreen, targetTexture = Surface - RenderTexture Surface { get; } // 2048×2048 ARGB32; the paper itself - Transform PaperRoot { get; } // parent of regions/pieces/paper bg - Vector2 DesignSize { get; } // world units, e.g. (20, 20) - Rect DesignRect { get; } // centered on origin, DesignSize wide -} - -public interface IArtInputBridge { - // Converts a screen-space pointer (Input System) to art-world coords - // inside the RT. Returns false if the pointer is outside the RawImage. - bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos); +public interface IPaperSurface { + RectTransform Root { get; } // PaperPanel — parent of slots/pieces/regions + RectTransform SlotsParent { get; } // parent for slot Images + RectTransform PiecesParent { get; } // parent for piece Images + RectTransform RegionsParent { get; } // parent for region Images + float DesignHalfSize { get; } // half of the reference square (e.g. 1024) } ``` -- `IPaperRig` is implemented by `PaperRig : MonoBehaviour` in the ColorBook scene. -- `IArtInputBridge` does the screen → RawImage local → UV → `ArtCamera.ViewportToWorldPoint` chain. -- All consumers (Coloring, ShapeBuilder, Capture, particle effects) read these from DI; they never touch `Screen.width/height` directly. +- Implemented by `PaperSurface : MonoBehaviour` in the ColorBook scene (sits on the `PaperPanel` GameObject). +- All paper-side features (`Coloring`, `ShapeBuilder`, `Capture`) parent their UI under one of these `RectTransform` slots and use canvas-local coords throughout. +- No `IPaperRig`. No `IArtInputBridge`. Input runs through Unity's `EventSystem` directly on the UI children. ### History @@ -444,7 +471,7 @@ public interface IUndoStack { ### Gallery & Capture -> `IGalleryService` is a Service contract → `Darkmatter.Core.Contracts.Services.Gallery`. `SavedArtworkDTO` is a runtime data struct → `Darkmatter.Core.Data.Dynamic.Features.Gallery`. `ICaptureService` → `Darkmatter.Core.Contracts.Services.Capture`. +> `IGalleryService` is a Service contract → `Darkmatter.Core.Contracts.Services.Gallery`. `SavedArtworkDTO` is a runtime data struct → `Darkmatter.Core.Data.Dynamic.Features.Gallery`. `ICaptureService` → `Darkmatter.Core.Contracts.Services.Capture`. `CaptureAsync` takes no args — implementation owns the `CaptureCamera` reference and renders the `PaperUI` layer to a one-shot RT. ```csharp namespace Darkmatter.Core.Data.Dynamic.Features.Gallery; @@ -469,13 +496,13 @@ public interface IGalleryService { namespace Darkmatter.Core.Contracts.Services.Capture; public interface ICaptureService { - // No camera or paperBg args — capture reads directly from IPaperRig.Surface. - // Dimensions inherited from the RT; no resize, no compositing. + // Allocates a temp RT, renders the CaptureCamera once (PaperUI layer only), + // ReadPixels into a Texture2D, encodes PNG, releases the RT. UniTask CaptureAsync(); } ``` -`ICaptureService` resolves `IPaperRig` via DI and reads `Surface` directly. The paper background is already baked into the RT because it sits in `PaperRoot` under the ArtCamera. No special compositing pass is ever needed. +`ICaptureService` owns a reference to `CaptureCamera` (a disabled `Camera` in the ColorBook scene). The capture camera's `cullingMask` is set to `PaperUI` so the HUD physically cannot appear in the PNG. The paper background is part of `PaperPanel`, so it's already in the right layer — no compositing pass. ### Signals @@ -519,25 +546,27 @@ public readonly struct ArtworkSavedSignal { ### `Paper` - Scene-scoped infrastructure. Lives in `ColorBook.unity` only. -- Owns `PaperRig` (MonoBehaviour) — exposes `ArtCamera`, the `RenderTexture Surface`, `PaperRoot` transform, and the design rect. -- Owns `ArtInputBridge` — converts pointer screen positions to art-world coords inside the RT. -- Registered in `ColorBookLifetimeScope` via `PaperRigModule`. All other features in the scene resolve `IPaperRig` / `IArtInputBridge` from DI. -- Lifetime is scene-scoped: created on scene load, destroyed on scene unload. RT is allocated in `Awake`, released in `OnDestroy`. +- Owns `PaperSurface` (MonoBehaviour) on the `PaperPanel` GameObject. Implements `IPaperSurface`, exposes `Root`, `SlotsParent`, `PiecesParent`, `RegionsParent`, `DesignHalfSize`. +- Registered in `ColorBookLifetimeScope` via `PaperSurfaceModule`. Other features resolve `IPaperSurface` from DI when they need to parent their UI under one of the role-specific `RectTransform`s. +- No render-target ownership. No input bridge. No coordinate conversion. The paper *is* the canvas children — nothing more. ### `ShapeBuilder` - Listens to `DrawingSelectedSignal`. -- Loads template via `IDrawingTemplateLoader`, parents shape pieces under `IPaperRig.PaperRoot` at off-slot positions inside the design rect. -- Per piece: drag with `ShapePieceView` (sprite + collider). Pointer events go through `IArtInputBridge.TryScreenToArtWorld`. On drop, check distance to `SlotPosition` against `SnapRadius`; if within, snap and lock. +- Loads template, spawns UI `Image` per piece under either `IPaperSurface.PiecesParent` or the HUD tray (depending on the FSM start state — usually tray). +- Each piece has `IBeginDragHandler` / `IDragHandler` / `IEndDragHandler` plus a per-piece `ShapePieceFsm`. Drag updates `RectTransform.anchoredPosition` directly from `PointerEventData` (converted to canvas-local via `RectTransformUtility.ScreenPointToLocalPointInRectangle`). +- On entering preview radius of the matching slot: reactive `Lerp` of `anchoredPosition` / `sizeDelta` / `localRotation` toward `SlotMarker`'s `RectTransform`. Drives off pointer distance, not time. +- On `OnEndDrag` inside snap radius: DOTween ease-out to exact slot pose, disable input. Otherwise DOTween back to tray slot. - Fires `ShapeAssembledSignal` when all pieces locked. ### `Coloring` - Listens to `ShapeAssembledSignal`. -- Spawns one `ColorRegionView` per `ColorRegionDTO` under `IPaperRig.PaperRoot` (sprite + polygon collider on `Artwork` layer). +- Spawns one UI `Image` per `ColorRegionDTO` under `IPaperSurface.RegionsParent`. Each region's `Image.alphaHitTestMinimumThreshold = 0.5f` so taps on transparent pixels pass through to the next region. +- Each region has `IPointerClickHandler`. On click → `ColoringController.PaintRegion(view)`. - Listens to palette selection (current color held in `ColoringStateRepository`). -- On pointer down: `IArtInputBridge.TryScreenToArtWorld(screenPos, out var artPos)` → `Physics2D.OverlapPoint(artPos, artworkMask)` → if hit, build `PaintRegionCommand(regionId, oldColor, newColor)`, push to `IUndoStack`. -- Command sets `SpriteRenderer.color` on undo/redo. +- Controller builds `PaintRegionCommand(regionId, oldColor, newColor)` and pushes to `IUndoStack`. +- Command sets `Image.color` on undo/redo. - Fires `ColorAppliedSignal` for SFX / sparkle effects. ### `History` @@ -550,7 +579,7 @@ public readonly struct ArtworkSavedSignal { ### `Capture` - Bound to the "Capture" button. -- Calls `ICaptureService.CaptureAsync()` → PNG bytes. Capture reads `IPaperRig.Surface` directly; no camera or paper-bg args needed. +- Calls `ICaptureService.CaptureAsync()` → PNG bytes. Implementation owns the disabled `CaptureCamera`, sets its `targetTexture` to a temp RT, calls `Render()` once, reads pixels, releases. - Hands bytes to `IGalleryService.SaveAsync(...)`. - Fires `ArtworkCapturedSignal` then `ArtworkSavedSignal`. - Shows a quick "saved!" toast with a thumbnail of the new entry. @@ -656,7 +685,7 @@ persistentDataPath/Gallery/ ## 12. Capture Pipeline -With the RT-paper-rig, capture has no setup phase. The RT is already the final image at all times. +A dedicated `CaptureCamera` lives in the scene, disabled by default. It renders only the `PaperUI` layer into a temp `RenderTexture` when capture fires. ``` [Capture button or Next button] @@ -664,13 +693,17 @@ With the RT-paper-rig, capture has no setup phase. The RT is already the final i ▼ ICaptureService.CaptureAsync() │ - ├─ rt = _paperRig.Surface (already populated each frame) + ├─ rt = RenderTexture.GetTemporary(2048, 2048, 0, ARGB32) + ├─ _captureCam.targetTexture = rt + ├─ _captureCam.Render() (one-shot; cullingMask = PaperUI only) + ├─ _captureCam.targetTexture = null ├─ prev = RenderTexture.active ├─ RenderTexture.active = rt - ├─ tex = new Texture2D(rt.width, rt.height, RGBA32, false) + ├─ tex = new Texture2D(2048, 2048, RGBA32, false) ├─ tex.ReadPixels(full rect, 0, 0); tex.Apply() ├─ RenderTexture.active = prev - ├─ bytes = tex.EncodeToPNG() (on worker via UniTask.RunOnThreadPool) + ├─ RenderTexture.ReleaseTemporary(rt) + ├─ bytes = tex.EncodeToPNG() (on worker via UniTask.RunOnThreadPool) ├─ Object.Destroy(tex) └─ return bytes ▼ @@ -686,10 +719,11 @@ EventBus.Publish(new ArtworkSavedSignal(dto)) Notes: -- HUD never appears in capture because the HUD is on `UICamera` / Canvas — it is physically in a different render path. The RT only ever sees `ArtCamera`'s output. -- Paper background is a sprite parented under `IPaperRig.PaperRoot` and is rendered into the RT every frame — already baked in. -- Saved PNGs are byte-comparable across devices because the RT dimensions and ArtCamera matrix never depend on screen size. -- `CaptureAsync` is safe to call repeatedly — no camera state is ever mutated. +- HUD never appears in capture because `CaptureCamera.cullingMask` excludes `HUDUI`. Layer mask, not coincidence — even if you accidentally parent a HUD element under `PaperPanel`, putting it on the wrong layer keeps it out. +- Paper background is just an `Image` on `PaperUI`. Already in the right layer; no special compositing. +- Saved PNGs are 2048×2048 on every device. `CaptureCamera` has fixed `orthographicSize` and aspect, independent of screen size. +- `CaptureAsync` is safe to call repeatedly. The CaptureCamera's transform / projection are set once at scene start and never modified. +- The temp RT is allocated via `RenderTexture.GetTemporary` so successive captures don't leak GPU memory. --- @@ -733,7 +767,7 @@ These shape several design decisions and are **non-negotiable**: - **No fail states.** Drawings cannot be "wrong". - **No timers.** Nothing decays or runs out. -- **No tiny hitboxes.** Drag tolerance ≥ 40 px; snap radius ≥ 60 px for shape pieces. +- **No tiny hitboxes.** Drag tolerance ≥ 40 canvas units; snap radius ≥ 80 canvas units for shape pieces. (Canvas reference resolution is 2048×2048 — these are anchored-position deltas, not screen px.) - **Auto-snap on near-miss.** If a piece is dropped within `1.5 × SnapRadius`, snap anyway and play a happy sound. - **No text-heavy UI.** Icons everywhere. Single-word labels max. - **Loud, immediate feedback.** Every tap plays a sound; every fill bursts a small particle effect. @@ -1101,48 +1135,67 @@ Same shape repeats for every feature's UI. ## 26. ShapeBuilder — Snap Algorithm -```csharp -// In ShapePieceView.OnPointerUp: -public void OnDragEnd(Vector2 worldPos) { - var slot = transform.position; // assigned target slot - var d = Vector2.Distance(worldPos, slot); +All math is in canvas-local space — `anchoredPosition`, `sizeDelta`, `localRotation`. No world coords. - if (d <= _piece.SnapRadius) { +```csharp +// In ShapePieceFsm.OnDragEnd (state: Dragging or Preview): +public void OnDragEnd() { + var pieceRT = _ui.RectTransform; + var slotRT = _targetSlot.RectTransform; + var d = Vector2.Distance(pieceRT.anchoredPosition, slotRT.anchoredPosition); + + if (d <= _cfg.SnapRadius) { SnapToSlot(); - } else if (d <= _piece.SnapRadius * 1.5f) { + } else if (d <= _cfg.SnapRadius * 1.5f) { // Toddler grace zone — snap anyway, play happy sound SnapToSlot(); - _audio.PlayOneShot(_clips.NiceTry); + _audio.PlayOneShot(SfxId.NiceTry); } else { ReturnToTrayAnimated(); } } private void SnapToSlot() { - _locked = true; - transform.DOMove(_piece.SlotPosition, 0.25f).SetEase(Ease.OutBack); - _audio.PlayOneShot(_clips.Snap); - _bus.Publish(new PieceSnappedSignal(_piece.PieceId)); + _ui.RectTransform.SetParent(_paper.PiecesParent, worldPositionStays: false); + var slot = _targetSlot.RectTransform; + _ui.RectTransform.DOAnchorPos(slot.anchoredPosition, 0.25f).SetEase(Ease.OutBack); + _ui.RectTransform.DOSizeDelta(slot.sizeDelta, 0.25f).SetEase(Ease.OutBack); + _ui.RectTransform.DOLocalRotateQuaternion(slot.localRotation, 0.25f).SetEase(Ease.OutBack); + _audio.PlayOneShot(SfxId.Snap); + _bus.Publish(new PieceSnappedSignal(_ui.PieceId)); } ``` +Three things to note: + +1. **Reparent** the piece from `TrayPanel` (HUD canvas) to `IPaperSurface.PiecesParent` (PaperCanvas) so it'll be included in capture. `worldPositionStays: false` because we want the new `anchoredPosition` to be relative to the new parent, not the world. +2. **Three simultaneous tweens** — position, size, rotation. Use `DOAnchorPos`, `DOSizeDelta`, `DOLocalRotateQuaternion`. They start together so the piece visually snaps as one motion. +3. **`SnapRadius` is in canvas units** (from `ShapeBuilderConfig`, e.g. 80–120), not world units. Same `CanvasScaler` reference resolution across devices = same hit feel. + Controller listens for `PieceSnappedSignal`, counts against expected piece count, fires `ShapeAssembledSignal` when complete. --- ## 27. Rendering Order & Sorting -URP 2D with a single `ArtCamera` ortho cam. +Canvas-only — order is sibling index inside `PaperPanel` (front-most is last in hierarchy). No URP 2D sorting layers. -| Sorting Layer | Order | Contents | -|---|---|---| -| `PaperBackground` | 0 | Paper bg sprite (under everything) | -| `ArtworkRegions` | 100 | `ColorRegionView` sprites (the colorable shapes) | -| `ArtworkPieces` | 200 | `ShapePieceView` sprites (during build) | -| `Effects` | 300 | Particle bursts, sparkles | -| `UIWorld` | 400 | World-space prompts (rare; mostly Canvas) | +`PaperPanel` children (bottom → top): -Canvas HUD lives on `UICamera` (Overlay), never sorts against `ArtCamera`. Capture renders only `ArtCamera`'s layers → HUD physically cannot leak into saved PNG. +``` +PaperPanel +├─ BackgroundImage (paper texture) +├─ RegionsPanel (colorable region Images) +├─ SlotsPanel (slot outline Images — under pieces so snapped pieces hide them) +├─ PiecesPanel (draggable / snapped piece Images) +└─ EffectsPanel (sparkle / particle UI for completion FX, optional) +``` + +`HUDCanvas` is a separate Canvas at a higher sorting order (or Screen Space - Overlay). It never sorts against `PaperCanvas` because they're different canvases. + +`CaptureCamera` renders only the `PaperUI` layer. The HUD physically cannot leak into the saved PNG because of the culling mask, regardless of sibling order. + +> If you ever need particles outside the canvas (e.g. confetti falling across the full screen on completion), use a separate Canvas above the HUD with its own sub-tree of UI particles. Don't add `ParticleSystem`s under PaperPanel — they don't render in UI canvases without `UIParticleSystem` or similar packages. --- @@ -1209,13 +1262,13 @@ Toddler-mode error UI: | Class | Layer | Asmdef | |---|---|---| | `IDrawingTemplate`, `ShapePieceDTO`, `ColorRegionDTO` | Core | `Core` | -| `IPaperRig`, `IArtInputBridge` | Core | `Core` | +| `IPaperSurface` | Core | `Core` | | `ICommand`, `IUndoStack` | Core | `Core` | | `BoundedUndoStack` | Libs | `Libs.CommandStack` | | `AddressableAssetProviderService` | Services | `Services.Assets` | | `FileGalleryService` | Services | `Services.Gallery` | | `RenderTextureCaptureService` | Services | `Services.Capture` | -| `PaperRig`, `ArtInputBridge`, `PaperRigModule` | Features | `Features.Paper` | +| `PaperSurface`, `PaperSurfaceModule` | Features | `Features.Paper` | | `ColoringController`, `PaintRegionCommand` | Features | `Features.Coloring` | | `ShapeBuilderController`, `ShapePieceView` | Features | `Features.ShapeBuilder` | | `HistoryController` | Features | `Features.History` | @@ -1305,26 +1358,18 @@ public interface ICaptureService { } ``` -#### `IPaperRig` *(Core/Contracts/Features/Paper — planned)* -Shared art rig. The single source of truth for everything that lives in the drawing world. +#### `IPaperSurface` *(Core/Contracts/Features/Paper — planned)* +The paper is just RectTransform real estate. Features parent their UI children under one of the role-specific roots. ```csharp -public interface IPaperRig { - Camera ArtCamera { get; } // offscreen, targetTexture = Surface - RenderTexture Surface { get; } // 2048×2048 ARGB32 — the paper itself - Transform PaperRoot { get; } // parent of regions/pieces/paper bg - Vector2 DesignSize { get; } // world units, e.g. (20, 20) - Rect DesignRect { get; } // centered on origin +public interface IPaperSurface { + RectTransform Root { get; } // PaperPanel itself + RectTransform SlotsParent { get; } // child of Root — for ShapeBuilder slot outlines + RectTransform PiecesParent { get; } // child of Root — for ShapeBuilder pieces (post-snap) + RectTransform RegionsParent { get; } // child of Root — for Coloring region Images + float DesignHalfSize { get; } // half the reference resolution side, in canvas units } ``` - -#### `IArtInputBridge` *(Core/Contracts/Features/Paper — planned)* -Converts screen-space pointer coords to art-world coords inside the RT. -```csharp -public interface IArtInputBridge { - bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos); -} -``` -Returns `false` when the pointer is outside the displayed RawImage rect (toddler tapped the HUD or backdrop). Every art-world raycast goes through this. +No render-target ownership. No coordinate conversion. The contract just hands out RectTransforms so features don't have to `Find` them. #### `IProgressionService` *(Core/Contracts/Features/Progression — planned)* Tracks which templates the child has completed and what they last opened. @@ -1386,9 +1431,16 @@ Implements `IGalleryService`. - **Delete flow:** delete png + thumb + json; missing files ignored (idempotent). #### `RenderTextureCaptureService` *(Services/Capture — planned)* -Implements `ICaptureService`. -- **Steps:** allocate `RenderTexture(width, height, 0, ARGB32)` → bind to `artCamera.targetTexture` → `artCamera.Render()` → `ReadPixels` into `Texture2D` → composite `paperBackground` underneath (single shader blit) → `EncodeToPNG` → release RT + textures. -- **Threading:** PNG encode happens on a `UniTask.RunOnThreadPool` to avoid hitching the main thread on tablets. +Implements `ICaptureService`. Drives the scene's disabled `CaptureCamera` once per capture. +```csharp +// fields: +// Camera _captureCam (scene-bound, registered via CaptureServiceModule) +// int _surfaceSize = 2048 +// IPathProvider _paths (only if you want to expose paths — usually not needed here) +``` +- **Steps:** `RenderTexture.GetTemporary(size, size, 0, ARGB32)` → set `_captureCam.targetTexture = rt` → `_captureCam.Render()` → `ReadPixels` into a `Texture2D` → null out the target texture → `RenderTexture.ReleaseTemporary(rt)` → `EncodeToPNG` → return bytes. +- **Threading:** PNG encode happens on `UniTask.RunOnThreadPool` to avoid hitching the main thread on tablets. +- **Camera setup:** `_captureCam` has `cullingMask = PaperUI`, `clearFlags = SolidColor` (white or paper color), `orthographicSize` and `aspect` cloned from `UICamera` once at scene start. Stays disabled — `Render()` is the only call site. - **Sizing:** default 2048², overridable. Capped at device max texture size. #### `JsonPersistenceService` *(Services/Persistence — planned; today `Libs/PlayerPrefs` covers small-key state)* @@ -1508,9 +1560,10 @@ public interface IDrawingCatalogView { #### `ShapeBuilderController` *(Systems)* Spawns shape pieces for the selected template, tracks snap progress, fires `ShapeAssembledSignal` when complete. ```csharp -// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory, IEventBus _bus, ShapeBuilderConfig _cfg +// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory, +// IPaperSurface _paper, TrayPanel _tray, IEventBus _bus, ShapeBuilderConfig _cfg public sealed class ShapeBuilderController : IDisposable { - public IReadOnlyList Active { get; } + public IReadOnlyList Active { get; } public UniTask BuildAsync(string templateId); // load template, spawn pieces in tray public void Reset(); // clear, unsubscribe } @@ -1519,97 +1572,101 @@ public sealed class ShapeBuilderController : IDisposable { ``` - **Internal:** counts `PieceSnappedSignal` against expected piece count. -#### `ShapePieceView : MonoBehaviour` *(Views)* -World-space draggable sprite with collider. Source for snap-or-return logic shown in section 26. +#### `ShapePieceUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler` *(UI)* +The UI Image that the toddler drags. Lives under `TrayPanel` while idle, reparents under `IPaperSurface.PiecesParent` when snapped. ```csharp -public sealed class ShapePieceView : MonoBehaviour { +public sealed class ShapePieceUI : MonoBehaviour, + IBeginDragHandler, IDragHandler, IEndDragHandler +{ public string PieceId { get; } public bool IsLocked { get; } - public event Action Snapped; // raised when piece locks into slot - public void Initialize(ShapePieceDTO dto, IInputReader input, IAudioService audio); + public RectTransform RectTransform { get; } + public event Action Snapped; + + public void Initialize(ShapePieceDTO dto, ShapePieceFsm fsm); } ``` -- **No public mutators** for position once locked — controller treats `IsLocked` as the source of truth. +- Handlers forward to `ShapePieceFsm` (`OnDragBegin / OnDrag(localPos) / OnDragEnd`). +- `OnDrag` converts `PointerEventData.position` to canvas-local via `RectTransformUtility.ScreenPointToLocalPointInRectangle` against the piece's parent rect. +- No collider, no Physics2D anywhere. + +#### `ShapePieceFsm` *(Systems)* +Per-piece state machine using `Libs.FSM`. States: `InTray → Dragging → Preview → (Snapped | Returning)`. +```csharp +// fields: ShapePieceUI _ui, SlotMarker _targetSlot, ShapeBuilderConfig _cfg, +// IAudioService _audio, IEventBus _bus +public sealed class ShapePieceFsm { + public void OnDragBegin(); + public void OnDrag(Vector2 canvasLocalPos); + public void OnDragEnd(); + public bool IsLocked { get; } +} +``` +- **Preview-state update**: reactive lerp of `anchoredPosition / sizeDelta / localRotation` toward `_targetSlot`'s pose, driven by `1 - dist/PreviewRadius`. No DOTween while previewing — it's per-frame. +- **Snapped enter**: DOTween ease-out to exact slot pose (~0.2s), disable drag, fire `PieceSnappedSignal`. +- **Returning enter**: DOTween back to tray slot (`anchoredPosition` from `TrayLayout`). + +#### `SlotMarker : MonoBehaviour` *(UI)* +The outline `Image` on `IPaperSurface.SlotsParent` showing where a piece should snap. Its `RectTransform` directly *is* the target pose — `ShapePieceFsm` reads `anchoredPosition`, `sizeDelta`, `localRotation` from it. +```csharp +public sealed class SlotMarker : MonoBehaviour { + public string SlotId; + public RectTransform RectTransform => transform as RectTransform; +} +``` + +#### `TrayPanel : MonoBehaviour` *(UI)* +HUD-side panel (on `HUDCanvas`) where pieces start out. Has a `HorizontalLayoutGroup` + `ContentSizeFitter`. Provides spawn anchors via `RectTransform Slot(int index)` for the controller. #### `ShapePieceFactory` *(Systems)* -Instantiates `ShapePieceView` prefabs from a pool. Avoids re-instantiating across "Next" cycles on the same template family. +Pool of `ShapePieceUI` GameObjects + their associated FSMs. Reused across template loads. ```csharp public sealed class ShapePieceFactory { - public ShapePieceView Spawn(ShapePieceDTO dto, Transform parent); - public void Despawn(ShapePieceView view); + public ShapePieceUI Spawn(ShapePieceDTO dto, RectTransform parent); + public void Despawn(ShapePieceUI piece); } ``` +#### `ShapeBuilderInputBinder` *(Systems)* +With UI handlers on the piece itself, an explicit input binder isn't strictly needed — drag events route via the EventSystem. Keep this class only if you need to listen for "any tap outside any piece" (e.g. to dismiss a preview). Otherwise skip. + --- ### 32.5b Feature — `Paper` *(planned)* -The shared art rig — RT, offscreen camera, screen↔world bridge. Every other feature in the ColorBook scene resolves `IPaperRig` and `IArtInputBridge` from DI and never touches `Screen.*` or `Camera.*` directly. +A tiny feature. Just exposes the paper RectTransforms via DI so consumers don't `Find` them. -#### `PaperRig : MonoBehaviour, IPaperRig` *(Rig)* -Scene-bound component placed on a GameObject in `ColorBook.unity`. Owns the RT lifecycle. +#### `PaperSurface : MonoBehaviour, IPaperSurface` *(Surface)* +Scene-bound component placed on the `PaperPanel` GameObject in `ColorBook.unity`. ```csharp // inspector fields: -// Camera _artCamera (Orthographic, aspect=1, fixed ortho size) -// Transform _paperRoot (parent of regions/pieces) -// Vector2 _designSize = (20, 20) (world units; matches 2048×2048 at PPU=100) -// int _surfaceSize = 2048 (RT side length, square) +// RectTransform _slotsParent +// RectTransform _piecesParent +// RectTransform _regionsParent +// float _designHalfSize = 1024f // half of 2048 reference resolution -public sealed class PaperRig : MonoBehaviour, IPaperRig { - public Camera ArtCamera => _artCamera; - public RenderTexture Surface => _surface; - public Transform PaperRoot => _paperRoot; - public Vector2 DesignSize => _designSize; - public Rect DesignRect => new(-_designSize / 2f, _designSize); +public sealed class PaperSurface : MonoBehaviour, IPaperSurface { + public RectTransform Root => (RectTransform)transform; + public RectTransform SlotsParent => _slotsParent; + public RectTransform PiecesParent => _piecesParent; + public RectTransform RegionsParent => _regionsParent; + public float DesignHalfSize => _designHalfSize; } ``` -- **Awake:** allocate `_surface = new RenderTexture(_surfaceSize, _surfaceSize, 0, ARGB32) { name = "PaperSurface" };` then `_surface.Create()` and `_artCamera.targetTexture = _surface; _artCamera.aspect = 1f; _artCamera.orthographicSize = _designSize.y / 2f;`. -- **OnDestroy:** `_surface.Release(); Object.Destroy(_surface);`. -- **No update logic** — the camera renders every frame automatically because `targetTexture` is set. -- **Important:** `_artCamera`'s `orthographicSize` and `aspect` are set once and never touched again. The RT contents are deterministic. +- No `Awake` / `OnDestroy` logic. The component is a pure pass-through to the RectTransforms. +- All four child rects share the same anchors and size as `Root` (anchored center, stretched to fill). -#### `ArtInputBridge : MonoBehaviour, IArtInputBridge` *(Input)* -Lives on the same UI Canvas as the paper `RawImage`. +#### `PaperSurfaceModule : MonoBehaviour, IServiceModule` *(Installers)* +Scene-scoped installer. Dragged into `ColorBookLifetimeScope.sceneModules[]`. ```csharp // inspector fields: -// RawImage _paperImage (the on-screen paper) -// RectTransform _paperRect (== _paperImage.rectTransform) -// Camera _uiCamera (Canvas event camera) -// IPaperRig _rig (injected via VContainer + IInjectable, or resolved in Start) - -public bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos) { - if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( - _paperRect, screenPos, _uiCamera, out var local)) { - artWorldPos = default; return false; - } - var rect = _paperRect.rect; - var uv = new Vector2( - (local.x - rect.xMin) / rect.width, - (local.y - rect.yMin) / rect.height); - if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1) { - artWorldPos = default; return false; - } - artWorldPos = _rig.ArtCamera.ViewportToWorldPoint(uv); - return true; -} -``` -- Returns `false` when the toddler tapped outside the RawImage (HUD button area, backdrop, off-screen). -- Used by every feature that does world-space picking — `Coloring`, `ShapeBuilder`, and any future feature like stickers. - -#### `PaperRigModule : MonoBehaviour, IServiceModule` *(Installers)* -Scene-scoped installer. Dragged onto `ColorBookLifetimeScope._installers[]`. -```csharp -// inspector fields: -// PaperRig _rig -// ArtInputBridge _bridge +// PaperSurface _surface public void Register(IContainerBuilder builder) { - builder.RegisterInstance(_rig); - builder.RegisterInstance(_bridge); + builder.RegisterInstance(_surface); } ``` -- Registers as `Instance` because both are MonoBehaviours already in the scene. -- Lifetime is implicitly tied to the scene (Unity destroys them on unload). +Registers as `Instance` because `PaperSurface` is a MonoBehaviour already in the scene. Lifetime tied to the scene. --- @@ -1632,7 +1689,8 @@ public sealed class ColoringStateRepository { #### `ColoringController` *(Systems)* — implements `IColoringController` Builds and pushes `PaintRegionCommand` instances; spawns `ColorRegionView` per region. ```csharp -// fields: IUndoStack _undo, ColoringStateRepository _state, ColorRegionFactory _factory, IEventBus _bus +// fields: IUndoStack _undo, ColoringStateRepository _state, ColorRegionFactory _factory, +// IPaperSurface _paper, IEventBus _bus public interface IColoringController { UniTask SpawnRegionsAsync(IDrawingTemplate template); void PaintRegion(ColorRegionView view); // builds command, pushes to undo stack @@ -1641,28 +1699,23 @@ public interface IColoringController { // sub: ShapeAssembledSignal (via flow controller, not direct) // pub: ColorAppliedSignal (via PaintRegionCommand) ``` +Spawns each region as a UI `Image` under `_paper.RegionsParent`. No `Physics2D`. -#### `ColorRegionView : MonoBehaviour` *(Views)* -Sprite + `PolygonCollider2D`, on `Artwork` layer. Tapped via `Physics2D.OverlapPoint` from `ColoringInputBinder`. +#### `ColorRegionView : MonoBehaviour, IPointerClickHandler` *(UI)* +UI Image with alpha-based hit detection. Tap routes through Unity's EventSystem directly to `OnPointerClick`. ```csharp -public sealed class ColorRegionView : MonoBehaviour { +public sealed class ColorRegionView : MonoBehaviour, IPointerClickHandler { public string RegionId { get; } - public Color Color { get; } // current paint - public void Initialize(ColorRegionDTO dto); + public Color Color => _image.color; + public void Initialize(ColorRegionDTO dto, IColoringController controller); public void SetColor(Color c); // setter only; no logic + public void OnPointerClick(PointerEventData e) => _controller.PaintRegion(this); } ``` +- **Required sprite setup:** sprite import inspector → **Read/Write Enabled = on**, **Generate Physics Shape = off** (not needed). `Image.alphaHitTestMinimumThreshold = 0.5f` on Initialize so taps on transparent pixels pass through to the next region below. +- **Sibling order matters** for stacked regions — top sibling gets first crack at the click; with alpha hit-test, transparent areas defer correctly. -#### `ColoringInputBinder` *(Systems)* — `IStartable, IDisposable` -Subscribes to `IInputReader.PointerDown`. On each tap: -1. `_bridge.TryScreenToArtWorld(screenPos, out var artPos)` — bail if outside the paper. -2. `Physics2D.OverlapPoint(artPos, _artworkMask)` against the `Artwork` layer. -3. If hit, `ColoringController.PaintRegion(hit.GetComponent())`. - -```csharp -// fields: IInputReader _input, IArtInputBridge _bridge, IColoringController _coloring, LayerMask _artworkMask -``` -Note: `_bridge` is the same instance the entire scene uses — no per-feature coordinate math. +No `ColoringInputBinder` class needed. Unity's EventSystem fires `OnPointerClick` on the topmost UI element under the pointer that consumes it — exactly what we want. #### `PaintRegionCommand` *(Commands)* Source in section 23. Holds `view`, `fromColor`, `toColor`, `bus`. Symmetrical execute/undo. @@ -1721,7 +1774,7 @@ public sealed class CaptureController { ``` - **Flow:** `_capture.CaptureAsync()` → `_gallery.SaveAsync(bytes, templateId)` → publish signals. - **Concurrency:** sets `IsCapturing = true` on entry; UI binds button enabled to `!IsCapturing` to prevent double-tap. -- **No camera or sprite args** — capture reads `IPaperRig.Surface` directly inside the service. +- **No camera or sprite args** — the implementation owns a reference to the disabled `CaptureCamera` and drives the one-shot render internally. #### `CaptureButtonPresenter` *(UI)* Wires button click → `CaptureController.CaptureCurrentAsync`. Disables button while in progress. Shows toast on `ArtworkSavedSignal`. @@ -1826,10 +1879,10 @@ All scope classes are thin: a serialized installer-MonoBehaviour list (+ optiona ### 32.13 Cross-cutting types -#### `ColorBookSceneRefs : MonoBehaviour` *(App — planned)* -Aggregates scene-bound Unity references that features need: `Camera artCamera`, `Transform catalogRoot`, `Transform builderRoot`, `Transform coloringRoot`, `RectTransform hudRoot`, `ColorPaletteView paletteView`, `HistoryButtonsView historyView`. Registered in `ColorBookLifetimeScope` via `builder.RegisterInstance(_sceneRefs)` so features don't `Find` things. +#### `ColorBookSceneRefs : MonoBehaviour` *(App — planned, optional)* +Aggregates HUD-side scene-bound Unity references that don't fit any single feature. Examples: `Camera captureCamera`, `RectTransform hudRoot`, `ColorPaletteView paletteView`, `HistoryButtonsView historyView`, `TrayPanel trayPanel`. Registered in `ColorBookLifetimeScope` via `builder.RegisterInstance(_sceneRefs)` so features don't `Find` things. -> Most of these refs are subsumed by `IPaperRig` now (which owns `ArtCamera` and `PaperRoot`). `ColorBookSceneRefs` reduces to the HUD-side refs (palette view, history buttons, panel roots). +> Paper-side refs are subsumed by `IPaperSurface` (which exposes the four canvas RectTransform roots). `CaptureCamera` could either live here or be exposed via its own dedicated `ICaptureCameraSource` contract — for v1, putting it on `ColorBookSceneRefs` is fine. #### `IServiceModule` *(Libs/Installers — ✅ exists)* ```csharp @@ -1850,15 +1903,17 @@ Implemented as `MonoBehaviour` per feature/service so scopes can drag them in th | `ColorBookLifetimeScope` | App | Scene DI | scene refs, installers | | `DrawingCatalogController` | Feature | Grid logic | catalog, bus | | `DrawingCatalogPresenter` | Feature | UI bridge | view, controller, catalog | -| `ShapeBuilderController` | Feature | Piece spawn + snap tracking | catalog, factory, bus, cfg | -| `ShapePieceView` | Feature | Draggable piece MB | input, audio | +| `ShapeBuilderController` | Feature | Piece spawn + snap tracking | catalog, factory, paper, tray, bus, cfg | +| `ShapePieceUI` | Feature | Draggable UI piece (Image + drag handlers) | fsm | +| `ShapePieceFsm` | Feature | Per-piece state machine | ui, slot, cfg, audio, bus | +| `SlotMarker` | Feature | Slot outline UI Image at target pose | — | +| `TrayPanel` | Feature | HUD-side tray with LayoutGroup | — | | `ColoringStateRepository` | Feature | Current color model | — | -| `ColoringController` | Feature | Region spawn + paint cmd | undo, state, factory, bus | -| `ColorRegionView` | Feature | Region sprite MB | — | -| `PaintRegionCommand` | Feature | Undoable paint | view, bus | -| `PaperRig` | Feature | RT + ArtCamera owner | — | -| `ArtInputBridge` | Feature | Screen→art-world picking | rig, raw image, ui cam | -| `PaperRigModule` | Feature | DI registration | rig, bridge | +| `ColoringController` | Feature | Region spawn + paint cmd | undo, state, factory, paper, bus | +| `ColorRegionView` | Feature | Region UI Image + IPointerClickHandler | controller | +| `PaintRegionCommand` | Feature | Undoable paint (sets Image.color) | view, bus | +| `PaperSurface` | Feature | IPaperSurface (Root + child rects) | — | +| `PaperSurfaceModule` | Feature | DI registration | surface | | `HistoryController` | Feature | Undo/redo facade | undo stack, bus | | `CaptureController` | Feature | Capture+save orchestration | capture svc, gallery, bus | | `ColorBookFlowController` | Feature | Scene FSM | bus, catalog, builder, coloring, capture, progression |