Readme asset

This commit is contained in:
Savya Bikram Shah
2026-05-27 13:13:47 +05:45
parent f18b78db24
commit d3d16439b6

491
Readme.md
View File

@@ -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; ~80120 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<byte[]> 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. 80120), 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<ShapePieceView> Active { get; }
public IReadOnlyList<ShapePieceUI> 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<string> Snapped; // raised when piece locks into slot
public void Initialize(ShapePieceDTO dto, IInputReader input, IAudioService audio);
public RectTransform RectTransform { get; }
public event Action<string> 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<IPaperRig>(_rig);
builder.RegisterInstance<IArtInputBridge>(_bridge);
builder.RegisterInstance<IPaperSurface>(_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<ColorRegionView>())`.
```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 |