# Phase 10 — Custom Prop Library and Sandbox Integration **Status:** Approved for implementation (2026-09-21). Implements the approved choices from `plans/PHASE_10_PROP_INTEGRATION.md`; this document specifies work and does not itself change runtime behavior. ## 1. Scope and existing seams The Sandbox already has the required three-mode physics lifecycle. `StageSpawner.spawn_prop_asset()` assigns `PropBlock.spawn_id`; `SandboxStage._finalize_placed_node()` freezes props and captures authored state; PLAY unfreezes them; EDIT/DIRECT restore authored transforms and clear velocities. Scene files already save prop `asset_id`, transform, scale, and material preset. Reuse those paths for custom props. The missing pieces are filesystem-backed prop entries, full layered visuals, a deliberate collision approximation, custom thumbnail keys and rendering, prop Browse/Refresh, and preservation of missing custom props during scene reload. `PropFile.flatten_to_payload()` currently chooses one shape; it remains a compatibility helper for existing single-shape consumers, not the custom prop visual representation. Runtime attachment, grip/emitter roles, prop editing inside the Sandbox, and `.prop` schema changes are outside this phase. ## 2. Architecture and contracts ```text res://props/*.prop ──scan──> PropLibrary entry ──id/path──> AssetSelector │ │ ├── parsed shapes ──> thumbnail queue ──> PropThumbnail │ └── ThumbnailCache └── on-demand id lookup ──> StageSpawner ──> PropBlock ├── Visuals/Shape_* └── one collision hull .scene asset_id ──> same on-demand lookup ──> prop or MissingPropPlaceholder ``` ### 2.1 `PropLibrary` (`class_name PropLibrary extends RefCounted`) Preserve `static func get_entries() -> Array[Dictionary]` as the **four built-in templates only**, and preserve `get_ids()` and `get_default_id() == "crate"`. Add: ```gdscript static func scan(dir_path: String = PropFile.DEFAULT_DIR) -> Array[Dictionary] static func make_custom_entry(path: String) -> Dictionary static func get_entry(id: String) -> Dictionary ``` `scan()` returns built-ins first, then valid `.prop` files in `dir_path`, sorted by case-insensitive display name and path. It does a fresh, non-recursive scan each call. Missing directory means built-ins only and one descriptive warning. `make_custom_entry()` accepts a `res://`, `user://`, or absolute filesystem `.prop` path, loads with `PropFile.load()` and `PropFile.from_file_dict()`, validates renderable geometry and a non-degenerate closed-shape collision hull, and returns `{}` with a path-specific warning on failure. It must not convert an `Array` of `{x,y}` dictionaries directly into `PackedVector2Array`; use the parsed `shapes` returned by `PropFile.from_file_dict()` or a dedicated coercion helper. `get_entry()` checks built-ins first; for a path ID it calls `make_custom_entry()` on demand, so a saved external prop resolves without first opening the selector. All entries expose these keys. GDScript cannot statically type dictionary fields; every method signature and local variable around this boundary remains statically typed. | Key | Type | Built-in | Custom | | --- | --- | --- | --- | | `id` | `String` | `"crate"`, etc. | canonical source path | | `name` | `String` | template name | `prop_name`, falling back to filename stem | | `material_preset` | `int` | template preset | parsed file preset | | `material_label` | `String` | existing label | corresponding preset label | | `is_custom` | `bool` | `false` | `true` | | `path` | `String` | `""` | same canonical path as `id` | | `payload` | `Dictionary` | existing primitive payload | compatibility value from `flatten_to_payload()` | | `shapes` | `Array[Dictionary]` | empty | full parsed shape stack, back to front | | `collision_hull` | `PackedVector2Array` | empty | validated local-space hull | The canonical custom ID is the normalized source path, including its scheme where applicable. Browse keeps an external absolute path; no file is copied. Scene portability therefore depends on that file remaining at the saved path. No last-selection setting is added. ### 2.2 Shared visual and collision conversion Add `scripts/prop_visuals.gd` (`class_name PropVisuals extends RefCounted`) as a stateless helper used by both runtime and thumbnail rendering: ```gdscript static func build(parent: Node2D, shapes: Array[Dictionary]) -> Rect2 static func collision_hull(shapes: Array[Dictionary]) -> PackedVector2Array ``` `build()` creates one visual group per source shape in array order. Closed polygons and circles receive a `Polygon2D` fill plus a closed `Line2D` outline. Open lines receive only a `Line2D`, with caps/joints matching the editor. Preserve each shape's fill, outline, width, and authored point coordinates. A circle uses its parsed points when present, including hand-reshaped circles; only an absent point list is regenerated from `radius`. The returned local bounds cover every rendered shape; empty or invalid individual shapes are ignored with a warning, while `PropLibrary` rejects an asset with no usable geometry. `collision_hull()` collects points from valid **closed** polygon/circle shapes and constructs one convex hull. Open lines never create collision. Reject a hull with fewer than three distinct, non-collinear points or zero area. The hull keeps authored local coordinates without terrain grid snapping. This is a deliberate approximate solid: cavities and concavities are filled, but the body remains stable with one collision node. Do not derive collision from the frontmost shape selected by `flatten_to_payload()`; for example, the briefcase's decorative details must not become its entire collision body. `PropBlock` (`class_name PropBlock extends RigidBody2D`) gains: ```gdscript func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void func has_custom_shapes() -> bool func get_visual_local_bounds() -> Rect2 ``` Custom nodes retain the existing `PropBlock` root, material mass/friction/bounce, contact signals, and one enabled `CollisionPolygon2D` populated from the hull. Their visual hierarchy is: ```text PropBlock (RigidBody2D; spawn_id = source path) ├── Visuals (Node2D) │ ├── Shape_0 (Node2D) ── Polygon2D? + Line2D │ └── Shape_1 ... ├── CollisionPolygon2D (one convex hull) └── existing primitive children (disabled/hidden for custom) ``` For custom props, changing `material_preset` applies physics properties but **does not replace authored per-shape colors**. Existing built-ins keep their current preset tinting and primitive structure. Reapplying the preset during scene load must preserve this distinction. `StageSpawner.spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock` branches on `is_custom`, uses `PropBlock.set_custom_shapes()` for custom entries, sets `spawn_id = asset_id`, and returns `null` if validation fails. `PropUtils.spawn_prop()` remains the built-in primitive path. `StageSelection.get_world_aabb()` and `StageSpawner.get_world_aabb()` must union all custom visual points (or transform `get_visual_local_bounds()`), rather than stop at the root `Polygon2D`. This keeps selection, gizmos, ghost placement, trigger overlap, and grid indexing aligned with the full visible prop. The current AABB hit-test remains approximate. ### 2.3 Thumbnail pipeline `PropThumbnail` keeps `render(payload: Dictionary, material_preset: int) -> Texture2D` for built-ins and adds `render_shapes(shapes: Array[Dictionary]) -> Texture2D` for custom props. Both are async render paths driven by the existing `SandboxStage._thumbnail_queue`, at most one queued render at a time. The custom path calls `PropVisuals.build()` and frames the camera around the **entire** stack; it never flattens the stack or recolors it from the material preset. A null/blank headless capture leaves the selector placeholder and does not poison the disk cache. `ThumbnailCache.prop_png(id)` remains built-in-only. `prop_file_key(path)`/`prop_file_png(path)` include a hash of the canonical **full path** and the file modification time, with a `file_` prefix distinct from built-in keys. Two `chair.prop` files in different directories cannot share a PNG key. On Refresh, the stage recomputes keys and queues only missing images. Cleanup may remove superseded custom PNGs for known canonical paths after successful replacement; it must not delete built-in PNGs or cache files for unrelated external paths. No global cache sweep is required. ### 2.4 Selector and stage signals `AssetSelector` keeps the existing typed signals `item_selected(entry: Dictionary)`, `browse_requested()`, `refresh_requested()`, and `cancelled()`. In `open("prop", entries)`, show Browse and Refresh, and use a prop-specific empty label. The selector's `_entry_key()` already uses `id` for props, so path IDs work without an additional key field. `set_entries()` still resets pagination and clears displayed thumbnail references; the stage repopulates them from cache or queue. `SandboxStage` calls `PropLibrary.scan()` whenever the prop selector opens or Refresh is pressed. Browse sets the existing `FileDialog` title/filter to `.prop` according to `_selector_kind`; stickman browsing retains `.stk`. A valid browsed prop selects its path ID and enters prop placement. An invalid file shows a concise error/toast and leaves the selector open. Refresh updates the open grid, invalidates stale thumbnail jobs through the existing generation counter, and preserves the selected prop ID when still resolvable. Custom entries choose `prop_file_png(path)`; built-ins choose `prop_png(id)`. ### 2.5 Scene reload and missing custom assets Add `scripts/missing_prop_placeholder.gd` (`class_name MissingPropPlaceholder extends Node2D`) with typed `original_entry: Dictionary`, `missing_prop_path: String`, `configure(entry: Dictionary) -> void`, and `get_area_rect() -> Rect2`. It displays an inert missing-asset marker and retains a deep copy of the original prop scene entry. The loader creates it **only for a missing/invalid path-valued custom prop ID**; unknown legacy template IDs continue to warn and skip. It applies the saved transform and scale and adds the placeholder to the World in the original object order. The placeholder is intentionally entered into `file_to_runtime` as an available object ID, so `StageSceneIO.decode_rules()` maps existing rule references to its instance ID. It does **not** belong in the `unavailable` set that currently makes rules disappear for missing stickmen. Its `Node2D` type prevents `PropBlock` physics/contact handling; stage trigger/action execution must treat it as inert. `_collect_scene_data()` recognizes `MissingPropPlaceholder` as a `prop`, begins from `original_entry.duplicate(true)`, updates the current file ID and transform/scale, and serializes its original `asset_id` and material. The normal runtime-to-file map includes its ID so `StageSceneIO.encode_rules()` writes the preserved references. Deleting a placeholder follows normal object/rule cleanup. Reopening the scene retries the path and replaces it with a real prop when available. Example scene flow: ```text load prop_1 -> path missing -> placeholder instance 842 -> rule source = 842 save -> object prop_1 still has original asset_id -> rule source_id = prop_1 restore file, reload -> real PropBlock instance 913 -> rule source = 913 ``` This extends preservation specifically to missing custom props; the existing missing-stickman skip policy remains outside this phase. ## 3. Mode and data flow ```mermaid stateDiagram-v2 EDIT --> DIRECT: select Direct EDIT --> PLAY: select Play DIRECT --> EDIT: select Edit DIRECT --> PLAY: select Play PLAY --> EDIT: freeze + restore authored pose PLAY --> DIRECT: freeze + restore authored pose ``` The custom prop goes through existing `_finalize_placed_node()`, `_save_object_state()`, `_enter_play_mode()`, and `_enter_edit_shared()` methods. Do not add a second authored-position metadata system. Stage save uses `prop.spawn_id` (the custom path) as `asset_id`. EDIT and DIRECT keep the body kinematically frozen; PLAY unfreezes it; leaving PLAY restores position/rotation and zeros linear/angular velocity. Current scale remains a scene-level transform. Scene load's material override must update physics without tinting custom artwork. ## 4. Verification and acceptance Add a focused `tests/test_phase10_prop_integration.gd` headless suite and update relevant existing suites. Assertions must test values and behavior, not only node counts. 1. **Library:** scan returns four built-ins plus the valid checked-in `.prop` entries in deterministic order; invalid version, corrupt JSON, missing file, empty/line-only, and degenerate closed geometry are skipped with warnings. `get_entries()` remains four templates, preserving `tests/test_phase3b_library.gd`; add an explicit scan test rather than changing that assertion. A custom ID resolves by path without prior scan. 2. **Geometry:** a multi-shape sample such as `briefcase.prop` spawns all authored layers, including open outlines where present, with original per-shape colors and point values after JSON parse. It has exactly one enabled collision hull that covers the intended closed-shape extrema; lines do not expand collision. `get_world_aabb()` and selection bounds include all visual layers. Built-in crate and ball remain visually/physically unchanged. 3. **Thumbnails:** the custom render path receives every layer and computes full-stack bounds; an interactive capture shows their colors and order. Two equal basenames from different folders have distinct cache paths, and changing mtime changes the key. A headless null capture is harmless. The stage queue still drains sequentially and ignores jobs from an older selector generation. 4. **Selector:** prop Browse/Refresh are visible; stickman Browse retains the `.stk` filter. Browse accepts a valid external `.prop` as a path ID; invalid browse keeps the selector open. Refresh finds a new file, drops a removed file from the grid, and does not replace a still-valid selected prop. Pagination remains correct past 12 items. 5. **Scene persistence:** save/reload retains a custom prop's path ID, material, position, rotation, scale, and full visuals. With its source temporarily absent, load creates a placeholder carrying the original entry; save/reload preserves that entry and a rule referring to it. Restoring the file and reopening resolves a real prop and the rule reference. Existing built-in prop and missing-stickman behavior stays covered by `tests/test_phase5_file_features.gd`. 6. **Lifecycle:** in EDIT/DIRECT the custom `RigidBody2D` is frozen; in PLAY it simulates and reports contacts; after PLAY→EDIT and PLAY→DIRECT it returns to the authored transform with zero velocities. Use a physics-frame runtime test and a brief interactive Sandbox check for full-layer artwork, collision, selection, Browse, and reload. Run the affected headless suites plus Godot 4.7.1 `--headless --check-only` on changed scripts. Test thumbnail pixels interactively because headless `SubViewport` capture can be blank. The target scene remains `res://scenes/sandbox_stage.tscn`; no new UI scene is required beyond the existing selector's code-controlled visibility/text. ## 5. Architectural trade-offs - A single convex collision hull is cheap and stable, but fills gaps and concavities. Full layered visual fidelity is independent of this approximation. - External Browse paths support immediate use without copying, but moving the source file breaks reload until restored. The placeholder prevents scene and rule data loss during that interval. - Dictionary entries match the existing libraries and `.prop` JSON boundary; introducing custom `Resource` assets would add conversion and migration work without improving this phase's data flow. - Path-plus-mtime thumbnail keys prevent same-name cross-folder collisions. Filesystems with coarse timestamp resolution can still miss two edits within one timestamp tick; content hashing is a possible later upgrade. - Missing-prop rule references remain serialized through inert placeholders. Rules cannot fire from absent geometry; existing missing-stickman rules continue to follow their current skip behavior.