Add visual blockout pipeline
This commit is contained in:
parent
4db20c237d
commit
139902246e
15 changed files with 1447 additions and 8 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ __pycache__/
|
|||
node_modules/
|
||||
state/scratch-compaction-*.md
|
||||
experiments/images/
|
||||
experiments/blender/
|
||||
|
|
|
|||
93
docs/architecture/visual-blockout-architecture.md
Normal file
93
docs/architecture/visual-blockout-architecture.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Visual Blockout Architecture
|
||||
|
||||
Ghostlight illustrated IF needs more than prompt continuity. A prompt can suggest
|
||||
that two images share a room; it cannot remember the room from another camera.
|
||||
For multi-angle illustrated replay, the durable artifact is a small spatial
|
||||
stage set.
|
||||
|
||||
## Purpose
|
||||
|
||||
A visual blockout plan defines a low-poly Blender scene used as a spatial and
|
||||
camera reference for image generation. It is not a semantic segmentation mask,
|
||||
and its flat colors are not a reliable label language for an image model. The
|
||||
blockout is a maquette: room shape, large object placement, character blocking,
|
||||
scale, and camera angle.
|
||||
|
||||
The image model still receives prose prompts for style, character identity,
|
||||
lighting, mood, and narrative emphasis. The blockout guide image supplies the
|
||||
floor plan the model does not actually know.
|
||||
|
||||
## Artifact Shape
|
||||
|
||||
A complete illustrated fixture may now have three separate visual surfaces:
|
||||
|
||||
- `.ink`: playable story structure
|
||||
- `.training.json`: branch/consequence training sidecar
|
||||
- `.visual.json`: image prompts, style cue, character refs, visible modifiers
|
||||
- `.blockout.json`: Blender-backed spatial guide plan for multi-angle replay
|
||||
|
||||
The blockout plan stores:
|
||||
|
||||
- `layout`: coordinate system, output `.blend`, scale notes, source refs
|
||||
- `semantic_materials`: readable blockout materials and their intended visual read
|
||||
- `geometry_modules`: procedural room organs such as shell, tunnel, manifold,
|
||||
catwalk, rail, and body-affordance stations
|
||||
- `proxy_actors`: mannequin or creature proxies with positions and scales
|
||||
- `cameras`: named shot cameras tied to `visual_scene_id` values
|
||||
- `guide_renders`: output guide images per camera/visual scene
|
||||
- `prompt_use_contract`: what the image model should preserve or reinterpret
|
||||
|
||||
## Blockout, Not Mask
|
||||
|
||||
Do not treat flat colors as a machine-readable segmentation contract. A general
|
||||
image model may loosely respect color and shape, but it has not agreed that
|
||||
orange means anchor rail or blue means supervisor authority. The blockout should
|
||||
therefore be readable in ordinary visual terms:
|
||||
|
||||
- the catwalk should look like a catwalk
|
||||
- the crawl throat should look like a tunnel mouth
|
||||
- the manifold should look like pipes and panels
|
||||
- Teth's station should look like an open work-support rig
|
||||
- humanoid proxies should mark human scale and blocking
|
||||
- cephalopod proxies should mark nonhuman body volume and reach
|
||||
|
||||
Color helps a human and may help the model, but shape carries the real burden.
|
||||
|
||||
## Geometry Script Direction
|
||||
|
||||
The first generator uses straightforward Blender Python primitives so the seam
|
||||
can run without a Blender add-on. The plan is shaped for Geometry Script / Geometry
|
||||
Nodes replacement:
|
||||
|
||||
- every procedural organ has a `geometry_script_tree` handle
|
||||
- every organ has parameter JSON
|
||||
- every organ names its material and object class
|
||||
- the generator can replace a Python primitive builder with a Geometry Script
|
||||
implementation one module at a time
|
||||
|
||||
This keeps the early blockout useful while leaving room for real procedural
|
||||
elaboration: repeated ribs, pipe runs, tunnel cutouts, cable trays, bolts,
|
||||
route panels, catwalk rails, work-support rigs, and crowd clusters.
|
||||
|
||||
## Prompt Use
|
||||
|
||||
When rendering with an image model, use the blockout guide as a reference image
|
||||
with language like:
|
||||
|
||||
```text
|
||||
Use the attached blockout as spatial and camera reference. Preserve the room
|
||||
layout, camera angle, large object positions, actor blocking, scale, and the
|
||||
open work-support object class. Interpret the blockout into the requested
|
||||
painterly style using the text prompt for character identity, lighting, mood,
|
||||
and material detail.
|
||||
```
|
||||
|
||||
Do not ask the image model to obey the blockout's colors as hidden labels. If a
|
||||
color matters as light or material, state it in the text prompt.
|
||||
|
||||
## Generated Files
|
||||
|
||||
Generated `.blend` files and render outputs belong under `experiments/blender/`
|
||||
unless a fixture intentionally promotes a blockout to a hand-maintained asset.
|
||||
The JSON plan and generator scripts are source; rendered guides are receipts or
|
||||
inputs for later image generation.
|
||||
|
|
@ -0,0 +1,556 @@
|
|||
{
|
||||
"schema_version": "ghostlight.visual_blockout_plan.v0",
|
||||
"blockout_plan_id": "pallas-species-strikes-service-ring-kappa-blockout-v0",
|
||||
"fixture_id": "pallas-species-strikes-branch-and-fold-v0",
|
||||
"visual_plan_ref": "examples/visual/pallas-species-strikes.branch-and-fold.v0.visual.json",
|
||||
"purpose": "Geometry-backed blockout plan for Service Ring Kappa. This is a spatial guide for illustrated IF image references, not a semantic segmentation mask.",
|
||||
"layout": {
|
||||
"layout_id": "service_ring_kappa",
|
||||
"layout_asset": "experiments/blender/pallas-species-strikes/service-ring-kappa.blockout.blend",
|
||||
"coordinate_system": "Blender default: Z up. The service lane runs along +Y, manifold wall sits on -X, baseline anchor rail sits on +X, supervisor catwalk is elevated on +X, and the Kappa crawl throat is near +Y.",
|
||||
"scale_notes": "One Blender unit is treated as one meter. Human proxies are roughly 1.7-1.9 units tall; Teth's proxy occupies a low horizontal work-support volume roughly 2.4m by 1.4m by 1.1m.",
|
||||
"source_refs": [
|
||||
"examples/lore-grounding/pallas-species-strikes.awakened-labor.v0.json",
|
||||
"examples/visual/pallas-species-strikes.branch-and-fold.v0.visual.json"
|
||||
]
|
||||
},
|
||||
"semantic_materials": [
|
||||
{
|
||||
"material_id": "shell_aggregate",
|
||||
"display_name": "Bloom shell aggregate",
|
||||
"base_color": [
|
||||
0.28,
|
||||
0.25,
|
||||
0.22,
|
||||
1
|
||||
],
|
||||
"intended_read": "Curved engineered habitat shell built from consolidated asteroid rubble/aggregate and dark TCS substrate.",
|
||||
"use_in_prompt": "Treat as rough engineered inner shell, not a solid natural asteroid cave."
|
||||
},
|
||||
{
|
||||
"material_id": "manifold_amber",
|
||||
"display_name": "Amber manifold wall",
|
||||
"base_color": [
|
||||
1,
|
||||
0.55,
|
||||
0.08,
|
||||
1
|
||||
],
|
||||
"intended_read": "Pipe-and-panel manifold wall with route screens, valves, pressure gauges, and diagnostic touch panels.",
|
||||
"use_in_prompt": "Preserve location and broad amber industrial glow; imagegen may reinterpret material detail."
|
||||
},
|
||||
{
|
||||
"material_id": "anchor_orange",
|
||||
"display_name": "Baseline anchor rail",
|
||||
"base_color": [
|
||||
0.95,
|
||||
0.32,
|
||||
0.08,
|
||||
1
|
||||
],
|
||||
"intended_read": "Bolted rail, hooks, tethers, and baseline rigger work zone.",
|
||||
"use_in_prompt": "Preserve rail side, hooks, and worker blocking."
|
||||
},
|
||||
{
|
||||
"material_id": "supervisor_blue",
|
||||
"display_name": "Supervisor catwalk and screens",
|
||||
"base_color": [
|
||||
0.15,
|
||||
0.42,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"intended_read": "Raised open supervisor catwalk with waist-high transparent safety rail and blue-white management displays.",
|
||||
"use_in_prompt": "Preserve elevation, open catwalk geometry, and blue authority-screen field."
|
||||
},
|
||||
{
|
||||
"material_id": "crawl_red",
|
||||
"display_name": "Kappa crawl throat",
|
||||
"base_color": [
|
||||
0.95,
|
||||
0.06,
|
||||
0.04,
|
||||
1
|
||||
],
|
||||
"intended_read": "Dark gasket-rimmed crawl opening and red-alert focal hazard.",
|
||||
"use_in_prompt": "Preserve as circular/oval tunnel mouth with red warning emphasis."
|
||||
},
|
||||
{
|
||||
"material_id": "teth_blue_gray",
|
||||
"display_name": "Teth and cephalopod work-support rig",
|
||||
"base_color": [
|
||||
0.18,
|
||||
0.32,
|
||||
0.42,
|
||||
1
|
||||
],
|
||||
"intended_read": "Cephalopod body and open dry-operation work-support frame.",
|
||||
"use_in_prompt": "Preserve nonhuman horizontal body volume and open work-support object class."
|
||||
},
|
||||
{
|
||||
"material_id": "worker_gray",
|
||||
"display_name": "Engineered seal technicians",
|
||||
"base_color": [
|
||||
0.55,
|
||||
0.58,
|
||||
0.58,
|
||||
1
|
||||
],
|
||||
"intended_read": "Clean gray engineered-worker skinsuits and coordinated line posture.",
|
||||
"use_in_prompt": "Preserve approximate worker cluster placement; do not rely on color as a model-readable label."
|
||||
},
|
||||
{
|
||||
"material_id": "rigger_brown_orange",
|
||||
"display_name": "Baseline anchor riggers",
|
||||
"base_color": [
|
||||
0.62,
|
||||
0.28,
|
||||
0.1,
|
||||
1
|
||||
],
|
||||
"intended_read": "Patched brown-orange baseline rigger jackets, hooks, gloves, and magnetic boots.",
|
||||
"use_in_prompt": "Preserve rigger cluster near anchor rail."
|
||||
}
|
||||
],
|
||||
"geometry_modules": [
|
||||
{
|
||||
"module_id": "curved_service_lane_shell",
|
||||
"object_class": "room_shape",
|
||||
"script_ref": "scripts/blender/build_visual_blockout.py",
|
||||
"geometry_script_tree": "service_ring_shell",
|
||||
"parameters": {
|
||||
"length": 18,
|
||||
"radius": 4.2,
|
||||
"arc_degrees": 130,
|
||||
"rib_count": 18
|
||||
},
|
||||
"material_id": "shell_aggregate",
|
||||
"notes": "Curved shell, deck floor, support ribs, and overhead pipe spine."
|
||||
},
|
||||
{
|
||||
"module_id": "kappa_crawl_throat",
|
||||
"object_class": "cutout_proxy",
|
||||
"script_ref": "scripts/blender/build_visual_blockout.py",
|
||||
"geometry_script_tree": "crawl_throat",
|
||||
"parameters": {
|
||||
"position": [
|
||||
0,
|
||||
8.2,
|
||||
1.15
|
||||
],
|
||||
"radius": 1.15,
|
||||
"depth": 0.35
|
||||
},
|
||||
"material_id": "crawl_red",
|
||||
"notes": "Visible oval tunnel mouth; boolean cutting can be added after the first blockout."
|
||||
},
|
||||
{
|
||||
"module_id": "manifold_wall",
|
||||
"object_class": "prop_cluster",
|
||||
"script_ref": "scripts/blender/build_visual_blockout.py",
|
||||
"geometry_script_tree": "manifold_wall",
|
||||
"parameters": {
|
||||
"position": [
|
||||
-3,
|
||||
1,
|
||||
1.25
|
||||
],
|
||||
"length": 11,
|
||||
"pipe_count": 8,
|
||||
"panel_count": 6
|
||||
},
|
||||
"material_id": "manifold_amber",
|
||||
"notes": "Repeated pipes, rectangular panels, valve disks, and screen blocks on the -X wall."
|
||||
},
|
||||
{
|
||||
"module_id": "baseline_anchor_rail",
|
||||
"object_class": "prop_cluster",
|
||||
"script_ref": "scripts/blender/build_visual_blockout.py",
|
||||
"geometry_script_tree": "anchor_rail",
|
||||
"parameters": {
|
||||
"position": [
|
||||
2.9,
|
||||
0,
|
||||
0.55
|
||||
],
|
||||
"length": 12,
|
||||
"hook_count": 5
|
||||
},
|
||||
"material_id": "anchor_orange",
|
||||
"notes": "Bolted rail with hook proxies and tethers on the +X side."
|
||||
},
|
||||
{
|
||||
"module_id": "supervisor_catwalk",
|
||||
"object_class": "elevated_platform",
|
||||
"script_ref": "scripts/blender/build_visual_blockout.py",
|
||||
"geometry_script_tree": "supervisor_catwalk",
|
||||
"parameters": {
|
||||
"position": [
|
||||
3.1,
|
||||
3.2,
|
||||
2.75
|
||||
],
|
||||
"length": 6.5,
|
||||
"width": 1.5,
|
||||
"rail_height": 0.9
|
||||
},
|
||||
"material_id": "supervisor_blue",
|
||||
"notes": "Open elevated platform, transparent safety rail proxy, and blue screen slabs."
|
||||
},
|
||||
{
|
||||
"module_id": "cephalopod_work_support_station",
|
||||
"object_class": "species_affordance_prop",
|
||||
"script_ref": "scripts/blender/build_visual_blockout.py",
|
||||
"geometry_script_tree": "cephalopod_work_support_station",
|
||||
"parameters": {
|
||||
"position": [
|
||||
1.8,
|
||||
4.7,
|
||||
0.95
|
||||
],
|
||||
"width": 2.5,
|
||||
"depth": 1.6,
|
||||
"height": 1.2
|
||||
},
|
||||
"material_id": "teth_blue_gray",
|
||||
"notes": "Open work-support frame with contact-band arcs, oxygenation tubes, and radial tool rails."
|
||||
}
|
||||
],
|
||||
"proxy_actors": [
|
||||
{
|
||||
"actor_id": "lio_vale",
|
||||
"visual_ref": "visual_character_refs.characters.lio_vale",
|
||||
"proxy_shape": "humanoid_capsule_with_slate",
|
||||
"material_id": "worker_gray",
|
||||
"default_position": [
|
||||
0,
|
||||
3.2,
|
||||
0.9
|
||||
],
|
||||
"default_rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
0.5,
|
||||
0.35,
|
||||
1.75
|
||||
],
|
||||
"notes": "Center-lane organizer proxy with small slate block."
|
||||
},
|
||||
{
|
||||
"actor_id": "ilya_marne",
|
||||
"visual_ref": "visual_character_refs.characters.ilya_marne",
|
||||
"proxy_shape": "humanoid_capsule_supervisor",
|
||||
"material_id": "supervisor_blue",
|
||||
"default_position": [
|
||||
3.2,
|
||||
3.4,
|
||||
3.65
|
||||
],
|
||||
"default_rotation": [
|
||||
0,
|
||||
0,
|
||||
-1.5708
|
||||
],
|
||||
"scale": [
|
||||
0.45,
|
||||
0.35,
|
||||
1.85
|
||||
],
|
||||
"notes": "Elevated on catwalk, visible above waist rail."
|
||||
},
|
||||
{
|
||||
"actor_id": "nara_7",
|
||||
"visual_ref": "visual_character_refs.characters.nara_7",
|
||||
"proxy_shape": "slender_humanoid_capsule",
|
||||
"material_id": "worker_gray",
|
||||
"default_position": [
|
||||
-2.15,
|
||||
3.85,
|
||||
0.85
|
||||
],
|
||||
"default_rotation": [
|
||||
0,
|
||||
0,
|
||||
1.5708
|
||||
],
|
||||
"scale": [
|
||||
0.4,
|
||||
0.3,
|
||||
1.7
|
||||
],
|
||||
"notes": "Placed near manifold with hand-facing wall posture."
|
||||
},
|
||||
{
|
||||
"actor_id": "orrin_dax",
|
||||
"visual_ref": "visual_character_refs.characters.orrin_dax",
|
||||
"proxy_shape": "broad_humanoid_capsule_with_hook",
|
||||
"material_id": "rigger_brown_orange",
|
||||
"default_position": [
|
||||
2.25,
|
||||
2.35,
|
||||
0.95
|
||||
],
|
||||
"default_rotation": [
|
||||
0,
|
||||
0,
|
||||
-1.5708
|
||||
],
|
||||
"scale": [
|
||||
0.62,
|
||||
0.42,
|
||||
1.8
|
||||
],
|
||||
"notes": "At anchor rail with a hook proxy."
|
||||
},
|
||||
{
|
||||
"actor_id": "teth_inkwise",
|
||||
"visual_ref": "visual_character_refs.characters.teth_inkwise",
|
||||
"proxy_shape": "cephalopod_mantle_with_tentacle_curves",
|
||||
"material_id": "teth_blue_gray",
|
||||
"default_position": [
|
||||
1.45,
|
||||
5,
|
||||
1
|
||||
],
|
||||
"default_rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"scale": [
|
||||
1.6,
|
||||
1.05,
|
||||
0.8
|
||||
],
|
||||
"notes": "Horizontal mantle proxy with tapering tentacle curves inside open work-support station."
|
||||
}
|
||||
],
|
||||
"cameras": [
|
||||
{
|
||||
"camera_id": "cam_yard_wake_wide",
|
||||
"visual_scene_ids": [
|
||||
"pallas_yard_wake",
|
||||
"pallas_service_ring_kappa",
|
||||
"pallas_ordinary_round_choice"
|
||||
],
|
||||
"position": [
|
||||
0,
|
||||
-7,
|
||||
2.4
|
||||
],
|
||||
"rotation": [
|
||||
1.22,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"target": [
|
||||
0,
|
||||
4.2,
|
||||
1.7
|
||||
],
|
||||
"lens_mm": 24,
|
||||
"shot_role": "wide establishing/service lane orientation",
|
||||
"framing_notes": "Shows shell curve, manifold wall, anchor rail, work-support bay, catwalk, and crawl throat in one readable room."
|
||||
},
|
||||
{
|
||||
"camera_id": "cam_teth_work_support_medium",
|
||||
"visual_scene_ids": [
|
||||
"pallas_teth_dry_operation",
|
||||
"pallas_teth_harness_check",
|
||||
"pallas_bypass_kit"
|
||||
],
|
||||
"position": [
|
||||
2.75,
|
||||
3.35,
|
||||
1.55
|
||||
],
|
||||
"rotation": [
|
||||
1.3,
|
||||
0,
|
||||
2.15
|
||||
],
|
||||
"target": [
|
||||
1.55,
|
||||
4.95,
|
||||
1.25
|
||||
],
|
||||
"lens_mm": 28,
|
||||
"shot_role": "cephalopod work-support station medium shot",
|
||||
"framing_notes": "Centers Teth, support loops, oxygenation tubes, radial tool rails, and Lio's inspection position without making the equipment read as containment."
|
||||
},
|
||||
{
|
||||
"camera_id": "cam_ilya_arrival_low",
|
||||
"visual_scene_ids": [
|
||||
"pallas_ilya_arrival",
|
||||
"pallas_quiet_safety_pause",
|
||||
"pallas_formalize_stoppage"
|
||||
],
|
||||
"position": [
|
||||
0.15,
|
||||
0.9,
|
||||
1.25
|
||||
],
|
||||
"rotation": [
|
||||
1.18,
|
||||
0,
|
||||
-0.45
|
||||
],
|
||||
"target": [
|
||||
3.15,
|
||||
3.35,
|
||||
3.35
|
||||
],
|
||||
"lens_mm": 28,
|
||||
"shot_role": "low floor view toward raised supervisor catwalk",
|
||||
"framing_notes": "Catwalk must read as open platform with rail, not glass booth or tank."
|
||||
},
|
||||
{
|
||||
"camera_id": "cam_climax_decision_line",
|
||||
"visual_scene_ids": [
|
||||
"pallas_climax_decision_line",
|
||||
"pallas_hold_for_recognition",
|
||||
"pallas_trade_bypass_for_evidence",
|
||||
"pallas_red_alert_worker_judgment"
|
||||
],
|
||||
"position": [
|
||||
-1.4,
|
||||
0.8,
|
||||
1.55
|
||||
],
|
||||
"rotation": [
|
||||
1.25,
|
||||
0,
|
||||
-0.2
|
||||
],
|
||||
"target": [
|
||||
1.2,
|
||||
4.75,
|
||||
1.4
|
||||
],
|
||||
"lens_mm": 30,
|
||||
"shot_role": "crowded decision-line ensemble",
|
||||
"framing_notes": "Keeps Lio center, Ilya elevated, Nara at manifold, Orrin at rail, Teth foreground-right work-support station, and crawl throat visible."
|
||||
},
|
||||
{
|
||||
"camera_id": "cam_object_callback_alcove",
|
||||
"visual_scene_ids": [
|
||||
"pallas_object_callback"
|
||||
],
|
||||
"position": [
|
||||
0.1,
|
||||
6.8,
|
||||
1.25
|
||||
],
|
||||
"rotation": [
|
||||
1.38,
|
||||
0,
|
||||
-2.65
|
||||
],
|
||||
"target": [
|
||||
2.4,
|
||||
5.4,
|
||||
0.9
|
||||
],
|
||||
"lens_mm": 50,
|
||||
"shot_role": "quiet object callback close shot",
|
||||
"framing_notes": "Frames coffee mug, repaired contact band, oxygenation tube, and background manifold touch ritual."
|
||||
}
|
||||
],
|
||||
"guide_renders": [
|
||||
{
|
||||
"render_id": "pallas_yard_wake.blockout",
|
||||
"camera_id": "cam_yard_wake_wide",
|
||||
"visual_scene_id": "pallas_yard_wake",
|
||||
"render_modes": [
|
||||
"flat_material",
|
||||
"clay",
|
||||
"depth_optional"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/blender/pallas-species-strikes/renders/pallas_yard_wake.blockout.png"
|
||||
],
|
||||
"imagegen_reference_use": "Use as spatial/camera reference for shell, service-lane curve, catwalk, anchor rail, manifold, and empty pre-shift infrastructure."
|
||||
},
|
||||
{
|
||||
"render_id": "pallas_teth_dry_operation.blockout",
|
||||
"camera_id": "cam_teth_work_support_medium",
|
||||
"visual_scene_id": "pallas_teth_dry_operation",
|
||||
"render_modes": [
|
||||
"flat_material",
|
||||
"clay",
|
||||
"depth_optional"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/blender/pallas-species-strikes/renders/pallas_teth_dry_operation.blockout.png"
|
||||
],
|
||||
"imagegen_reference_use": "Use as spatial/camera reference for Teth's open work-support station and nonhuman body affordance."
|
||||
},
|
||||
{
|
||||
"render_id": "pallas_ilya_arrival.blockout",
|
||||
"camera_id": "cam_ilya_arrival_low",
|
||||
"visual_scene_id": "pallas_ilya_arrival",
|
||||
"render_modes": [
|
||||
"flat_material",
|
||||
"clay",
|
||||
"depth_optional"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/blender/pallas-species-strikes/renders/pallas_ilya_arrival.blockout.png"
|
||||
],
|
||||
"imagegen_reference_use": "Use as spatial/camera reference for Ilya entering from an open catwalk above the floor."
|
||||
},
|
||||
{
|
||||
"render_id": "pallas_climax_decision_line.blockout",
|
||||
"camera_id": "cam_climax_decision_line",
|
||||
"visual_scene_id": "pallas_climax_decision_line",
|
||||
"render_modes": [
|
||||
"flat_material",
|
||||
"clay",
|
||||
"depth_optional"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/blender/pallas-species-strikes/renders/pallas_climax_decision_line.blockout.png"
|
||||
],
|
||||
"imagegen_reference_use": "Use as spatial/camera reference for the ensemble decision-line shot and large object positions."
|
||||
},
|
||||
{
|
||||
"render_id": "pallas_object_callback.blockout",
|
||||
"camera_id": "cam_object_callback_alcove",
|
||||
"visual_scene_id": "pallas_object_callback",
|
||||
"render_modes": [
|
||||
"flat_material",
|
||||
"clay",
|
||||
"depth_optional"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/blender/pallas-species-strikes/renders/pallas_object_callback.blockout.png"
|
||||
],
|
||||
"imagegen_reference_use": "Use as spatial/camera reference for the small aftermath object composition."
|
||||
}
|
||||
],
|
||||
"prompt_use_contract": {
|
||||
"reference_image_role": "Blockout guide render is a spatial/camera reference, not a segmentation mask or exact color contract.",
|
||||
"must_preserve": [
|
||||
"camera angle and broad composition",
|
||||
"room shell curve and large object placement",
|
||||
"relative positions of named actors and worker clusters",
|
||||
"Teth's open work-support object class",
|
||||
"Ilya's open raised catwalk object class"
|
||||
],
|
||||
"may_reinterpret": [
|
||||
"surface detail density",
|
||||
"exact material finish",
|
||||
"lighting texture",
|
||||
"painterly stroke treatment",
|
||||
"small pipe and cable routing"
|
||||
],
|
||||
"must_not_assume": [
|
||||
"semantic colors are a reliable label language for imagegen",
|
||||
"the blockout replaces character refs or prose prompt text",
|
||||
"a new camera angle can preserve geometry without a matching blockout camera"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"schema_version": "ghostlight.ink_visual_plan.v0",
|
||||
"visual_plan_id": "pallas-species-strikes-branch-and-fold-visual-v0",
|
||||
"blockout_plan_ref": "examples/visual-blockouts/pallas-species-strikes.service-ring-kappa.v0.blockout.json",
|
||||
"ink_ref": "examples/ink/pallas-species-strikes.branch-and-fold.v0.ink",
|
||||
"source_training_annotation_ref": "examples/ink/pallas-species-strikes.branch-and-fold.v0.training.json",
|
||||
"scene_id": "pallas-species-strikes-branch-and-fold-v0",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ path is a source-grounded branching-scene data loop:
|
|||
- Narrative quality reviewer prompt: `prompts/narrative-quality-reviewer.md`
|
||||
- Visual scene continuity reviewer prompt:
|
||||
`prompts/visual-scene-continuity-reviewer.md`
|
||||
- Visual blockout architecture: `docs/architecture/visual-blockout-architecture.md`
|
||||
- Visual blockout schema: `schemas/visual-blockout-plan.schema.json`
|
||||
- Research-enabled Sella packet: `examples/responder-packets/scene-02-sanctuary-intake.sella_ren.packet.research.v0.json`
|
||||
- Research-enabled Sella capture: `experiments/responder-packets/cold-wake-sanctuary-intake-sella-research-enabled-v0.capture.json`
|
||||
- Research-enabled Sella mutation: `experiments/responder-packets/cold-wake-sanctuary-intake-sella-research-enabled-v0.mutation.json`
|
||||
|
|
@ -96,6 +98,8 @@ path is a source-grounded branching-scene data loop:
|
|||
- Pallas Species Strikes Ink scaffold: `examples/ink/pallas-species-strikes.branch-and-fold.v0.ink`
|
||||
- Pallas Species Strikes Ink sidecar: `examples/ink/pallas-species-strikes.branch-and-fold.v0.training.json`
|
||||
- Pallas Species Strikes visual plan: `examples/visual/pallas-species-strikes.branch-and-fold.v0.visual.json`
|
||||
- Pallas Species Strikes Service Ring Kappa blockout plan:
|
||||
`examples/visual-blockouts/pallas-species-strikes.service-ring-kappa.v0.blockout.json`
|
||||
- Pallas Species Strikes readable run: `experiments/pallas-species-strikes/pallas-species-strikes.branch-and-fold-clean-run.v0.md`
|
||||
- Branch compiler and IF reviewer contract: `docs/architecture/ink-branching-scenes.md`
|
||||
|
||||
|
|
@ -163,13 +167,19 @@ mappings. It now segments the introduction, branch locations, Kappa red alert,
|
|||
climax decision, and aftermath into click-through visual sections with
|
||||
`visual_scene_id` anchors, stable `visual_character_refs`, a global Media
|
||||
Molecule Dreams-style volumetric brushstroke cue, and imagegen-ready prompts
|
||||
that explain fictional equipment in visible terms. Its location grounding now
|
||||
that explain fictional equipment in visible terms. A new visual blockout seam
|
||||
adds a Blender-backed `ghostlight.visual_blockout_plan.v0` maquette for Service
|
||||
Ring Kappa: room shape, scale, large object placement, actor blocking, and
|
||||
camera angles. The blockout guide is a spatial reference for image generation,
|
||||
not a segmentation mask or hidden color contract. Its location grounding now
|
||||
uses the Bloom process correctly: engineered rotating shell, consolidated
|
||||
asteroid rubble/aggregate, TCS substrate, seals, supports, and service systems,
|
||||
with image prompts written as affirmative visible targets. Treat it as
|
||||
coordinator/IF scaffold training data, not raw responder gold.
|
||||
coordinator/IF scaffold training data plus illustrated replay collateral, not
|
||||
raw responder gold.
|
||||
|
||||
Next: add the corpus coverage ledger seam or choose the next Aetheria fixture
|
||||
Next: promote the blockout seam only as far as needed for the Pallas fixture,
|
||||
then add the corpus coverage ledger seam or choose the next Aetheria fixture
|
||||
from the 100-150 broad coverage target.
|
||||
|
||||
## Warnings
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ Aetheria lore/source context
|
|||
-> mutation receipt
|
||||
-> updated scene/world/social state
|
||||
-> coordinator continuity and next-beat plan
|
||||
-> branch compiler materializes Ink + sidecar + visual plan + compiler notes
|
||||
-> branch compiler materializes Ink + sidecar + visual plan + optional blockout plan + compiler notes
|
||||
-> IF artifact reviewer audits consequence, fold, and visual continuity
|
||||
-> visual scene reviewer audits click-through segmentation and imagegen-ready prompts
|
||||
-> accepted fixture / future training corpus + optional illustrated replay collateral
|
||||
|
|
@ -67,6 +67,14 @@ Names are handles, not faces. The visual reviewer judges the playable Ink
|
|||
experience and visual plan; clean-run prose receipts are debugging mirrors, not
|
||||
the reviewed visual surface.
|
||||
|
||||
Multi-angle illustrated replay needs geometry, not hopeful prose continuity.
|
||||
When a fixture needs several camera angles in the same space, emit a
|
||||
`.blockout.json` plan and generate Blender guide renders. The blockout is a
|
||||
spatial maquette for room shape, scale, large object placement, actor blocking,
|
||||
and camera angle. It is not a segmentation mask, and flat semantic colors are
|
||||
human-readable helper marks rather than a secret language the image model is
|
||||
expected to obey.
|
||||
|
||||
## Important Contracts
|
||||
|
||||
- Agent state: `schemas/agent-state.schema.json`
|
||||
|
|
@ -77,6 +85,8 @@ the reviewed visual surface.
|
|||
- Responder packets: `schemas/responder-packet.schema.json`
|
||||
- Responder outputs: `schemas/responder-output.schema.json`
|
||||
- Ink branch contract: `docs/architecture/ink-branching-scenes.md`
|
||||
- Visual blockout contract: `docs/architecture/visual-blockout-architecture.md`
|
||||
- Visual blockout schema: `schemas/visual-blockout-plan.schema.json`
|
||||
- Training stages and corpus gates: `docs/architecture/training-plan.md`
|
||||
|
||||
## Current Live Example
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ not carry live next-action lists.
|
|||
character refs only for characters actually visible in the rendered frame;
|
||||
keep continuity notes as tooling constraints unless rewritten as visible
|
||||
prompt text.
|
||||
- Visual blockout correction: for multi-angle illustrated replay, prompt
|
||||
continuity is not enough. Emit a `.blockout.json` plan for any fixture
|
||||
that needs stable room geometry across several cameras, then generate
|
||||
Blender guide renders. Treat the blockout as a spatial/camera maquette
|
||||
for room shape, scale, large objects, actor blocking, and shot angle, not
|
||||
as a segmentation mask or a hidden color contract for imagegen.
|
||||
- IF review correction: run an independent artifact reviewer after branch/Ink generation and before acceptance. The reviewer audits fake variables, cosmetic choices, missing state reads, fake folds, unearned convergence, state-named-instead-of-checked prose, visual callback gaps, and endings that ignore major state. The first Cold Wake user review is a seed failure taxonomy for this evaluator path.
|
||||
- Visual review correction: run a visual scene continuity reviewer for
|
||||
illustrated fixtures. It audits oversized Ink screens, missing visual
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"state:status": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_state.py status",
|
||||
"state:prepare-compaction": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_prepare_compaction.py",
|
||||
"agent-state:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_agent_state.py",
|
||||
"schema:validate": "npm run agent-state:validate && npm run projection:validate && npm run projected-context:validate && npm run lore-grounding:validate && npm run coordinator:validate && npm run responder-packets:validate && npm run responder-outputs:validate && npm run qwen-captures:validate && npm run ink:validate",
|
||||
"schema:validate": "npm run agent-state:validate && npm run projection:validate && npm run projected-context:validate && npm run lore-grounding:validate && npm run coordinator:validate && npm run responder-packets:validate && npm run responder-outputs:validate && npm run qwen-captures:validate && npm run ink:validate && npm run blockout:validate",
|
||||
"projection:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_projection_examples.py",
|
||||
"projected-context:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_projected_contexts.py",
|
||||
"lore-grounding:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_lore_grounding.py",
|
||||
|
|
@ -17,7 +17,9 @@
|
|||
"qwen-captures:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_qwen_captures.py",
|
||||
"ink:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_ink_examples.py",
|
||||
"qwen:check-tools": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\check_qwen_chat_tools.py",
|
||||
"images:bfl": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\scripts\\generate_bfl_images.py"
|
||||
"images:bfl": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\scripts\\generate_bfl_images.py",
|
||||
"blockout:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_visual_blockouts.py",
|
||||
"blockout:build": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\scripts\\run_blender_blockout.py"
|
||||
},
|
||||
"devDependencies": {
|
||||
"inkjs": "^2.4.0"
|
||||
|
|
|
|||
156
schemas/visual-blockout-plan.schema.json
Normal file
156
schemas/visual-blockout-plan.schema.json
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://gamecult.dev/ghostlight/schemas/visual-blockout-plan.schema.json",
|
||||
"title": "Ghostlight Visual Blockout Plan",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"blockout_plan_id",
|
||||
"fixture_id",
|
||||
"visual_plan_ref",
|
||||
"purpose",
|
||||
"layout",
|
||||
"semantic_materials",
|
||||
"geometry_modules",
|
||||
"proxy_actors",
|
||||
"cameras",
|
||||
"guide_renders",
|
||||
"prompt_use_contract"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": { "const": "ghostlight.visual_blockout_plan.v0" },
|
||||
"blockout_plan_id": { "type": "string", "minLength": 1 },
|
||||
"fixture_id": { "type": "string", "minLength": 1 },
|
||||
"visual_plan_ref": { "type": "string", "minLength": 1 },
|
||||
"purpose": { "type": "string", "minLength": 1 },
|
||||
"layout": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["layout_id", "layout_asset", "coordinate_system", "scale_notes", "source_refs"],
|
||||
"properties": {
|
||||
"layout_id": { "type": "string", "minLength": 1 },
|
||||
"layout_asset": { "type": "string", "minLength": 1 },
|
||||
"coordinate_system": { "type": "string", "minLength": 1 },
|
||||
"scale_notes": { "type": "string", "minLength": 1 },
|
||||
"source_refs": { "$ref": "#/$defs/stringArray" }
|
||||
}
|
||||
},
|
||||
"semantic_materials": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["material_id", "display_name", "base_color", "intended_read", "use_in_prompt"],
|
||||
"properties": {
|
||||
"material_id": { "type": "string", "minLength": 1 },
|
||||
"display_name": { "type": "string", "minLength": 1 },
|
||||
"base_color": {
|
||||
"type": "array",
|
||||
"minItems": 4,
|
||||
"maxItems": 4,
|
||||
"items": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
},
|
||||
"intended_read": { "type": "string", "minLength": 1 },
|
||||
"use_in_prompt": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"geometry_modules": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["module_id", "object_class", "script_ref", "geometry_script_tree", "parameters", "material_id", "notes"],
|
||||
"properties": {
|
||||
"module_id": { "type": "string", "minLength": 1 },
|
||||
"object_class": { "type": "string", "minLength": 1 },
|
||||
"script_ref": { "type": "string", "minLength": 1 },
|
||||
"geometry_script_tree": { "type": "string", "minLength": 1 },
|
||||
"parameters": { "type": "object" },
|
||||
"material_id": { "type": "string", "minLength": 1 },
|
||||
"notes": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"proxy_actors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["actor_id", "visual_ref", "proxy_shape", "material_id", "default_position", "default_rotation", "scale", "notes"],
|
||||
"properties": {
|
||||
"actor_id": { "type": "string", "minLength": 1 },
|
||||
"visual_ref": { "type": "string", "minLength": 1 },
|
||||
"proxy_shape": { "type": "string", "minLength": 1 },
|
||||
"material_id": { "type": "string", "minLength": 1 },
|
||||
"default_position": { "$ref": "#/$defs/vector3" },
|
||||
"default_rotation": { "$ref": "#/$defs/vector3" },
|
||||
"scale": { "$ref": "#/$defs/vector3" },
|
||||
"notes": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["camera_id", "visual_scene_ids", "position", "rotation", "lens_mm", "shot_role", "framing_notes"],
|
||||
"properties": {
|
||||
"camera_id": { "type": "string", "minLength": 1 },
|
||||
"visual_scene_ids": { "$ref": "#/$defs/stringArray" },
|
||||
"position": { "$ref": "#/$defs/vector3" },
|
||||
"rotation": { "$ref": "#/$defs/vector3" },
|
||||
"target": { "$ref": "#/$defs/vector3" },
|
||||
"lens_mm": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"shot_role": { "type": "string", "minLength": 1 },
|
||||
"framing_notes": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"guide_renders": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["render_id", "camera_id", "visual_scene_id", "render_modes", "output_refs", "imagegen_reference_use"],
|
||||
"properties": {
|
||||
"render_id": { "type": "string", "minLength": 1 },
|
||||
"camera_id": { "type": "string", "minLength": 1 },
|
||||
"visual_scene_id": { "type": "string", "minLength": 1 },
|
||||
"render_modes": { "$ref": "#/$defs/stringArray" },
|
||||
"output_refs": { "$ref": "#/$defs/stringArray" },
|
||||
"imagegen_reference_use": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"prompt_use_contract": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["reference_image_role", "must_preserve", "may_reinterpret", "must_not_assume"],
|
||||
"properties": {
|
||||
"reference_image_role": { "type": "string", "minLength": 1 },
|
||||
"must_preserve": { "$ref": "#/$defs/stringArray" },
|
||||
"may_reinterpret": { "$ref": "#/$defs/stringArray" },
|
||||
"must_not_assume": { "$ref": "#/$defs/stringArray" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"stringArray": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"vector3": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"items": { "type": "number" }
|
||||
}
|
||||
}
|
||||
}
|
||||
31
scripts/blender/README.md
Normal file
31
scripts/blender/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Blender Blockout Scripts
|
||||
|
||||
These scripts build low-poly spatial guide scenes for Ghostlight illustrated IF.
|
||||
They are not final art. They are stage maquettes for image generation.
|
||||
|
||||
## Blender Path
|
||||
|
||||
Blender may not be on `PATH`. The known local install is:
|
||||
|
||||
```powershell
|
||||
C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe
|
||||
```
|
||||
|
||||
You can pass another path to the npm wrapper with `--blender`.
|
||||
|
||||
## Build Pallas Blockout
|
||||
|
||||
```powershell
|
||||
npm run blockout:validate
|
||||
npm run blockout:build -- --plan examples\visual-blockouts\pallas-species-strikes.service-ring-kappa.v0.blockout.json --save-blend --render-guides
|
||||
```
|
||||
|
||||
The build script runs Blender in background mode and writes generated files under
|
||||
`experiments/blender/` by default.
|
||||
|
||||
## Geometry Script Direction
|
||||
|
||||
The current builder uses plain `bpy` primitives for reliability. The plan format
|
||||
already names `geometry_script_tree` handles for each procedural organ, so the
|
||||
primitive builders can be replaced with Geometry Script / Geometry Nodes modules
|
||||
without changing downstream fixture data.
|
||||
359
scripts/blender/build_visual_blockout.py
Normal file
359
scripts/blender/build_visual_blockout.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
"""Build Ghostlight visual blockout scenes in Blender from a blockout plan.
|
||||
|
||||
Run with Blender, for example:
|
||||
|
||||
"C:\\Program Files (x86)\\Steam\\steamapps\\common\\Blender\\blender.exe" --background --python scripts/blender/build_visual_blockout.py -- --plan examples/visual-blockouts/pallas-species-strikes.service-ring-kappa.v0.blockout.json --save-blend --render-guides
|
||||
|
||||
This first generator is intentionally low-poly and fully scripted. The module
|
||||
names and `geometry_script_tree` handles in the JSON are the seam where Carson
|
||||
Katri's Geometry Script / Geometry Nodes implementations can replace the simple
|
||||
bpy primitives module by module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
except Exception as exc: # pragma: no cover - only importable inside Blender.
|
||||
raise SystemExit("This script must be run with Blender's Python interpreter.") from exc
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def parse_script_args() -> argparse.Namespace:
|
||||
argv = sys.argv
|
||||
if "--" in argv:
|
||||
argv = argv[argv.index("--") + 1 :]
|
||||
else:
|
||||
argv = []
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--plan", type=Path, required=True)
|
||||
parser.add_argument("--save-blend", action="store_true")
|
||||
parser.add_argument("--render-guides", action="store_true")
|
||||
parser.add_argument("--output-blend", type=Path)
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
parser.add_argument("--resolution-x", type=int, default=1536)
|
||||
parser.add_argument("--resolution-y", type=int, default=864)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def load_plan(path: Path) -> dict[str, Any]:
|
||||
plan_path = path if path.is_absolute() else ROOT / path
|
||||
return json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def resolve_project_path(path: str | Path) -> Path:
|
||||
value = Path(path)
|
||||
return value if value.is_absolute() else ROOT / value
|
||||
|
||||
|
||||
def clear_scene() -> None:
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
bpy.context.scene.unit_settings.system = "METRIC"
|
||||
|
||||
|
||||
def make_material(material_id: str, color: list[float]) -> bpy.types.Material:
|
||||
material = bpy.data.materials.new(material_id)
|
||||
material.diffuse_color = tuple(color)
|
||||
return material
|
||||
|
||||
|
||||
def material_map(plan: dict[str, Any]) -> dict[str, bpy.types.Material]:
|
||||
return {
|
||||
item["material_id"]: make_material(item["material_id"], item["base_color"])
|
||||
for item in plan["semantic_materials"]
|
||||
}
|
||||
|
||||
|
||||
def add_cube(name: str, location: tuple[float, float, float], scale: tuple[float, float, float], material: bpy.types.Material) -> bpy.types.Object:
|
||||
bpy.ops.mesh.primitive_cube_add(size=1.0, location=location)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.dimensions = scale
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
obj.data.materials.append(material)
|
||||
return obj
|
||||
|
||||
|
||||
def add_cylinder(name: str, location: tuple[float, float, float], radius: float, depth: float, material: bpy.types.Material, vertices: int = 48, rotation: tuple[float, float, float] = (0, 0, 0)) -> bpy.types.Object:
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=depth, location=location, rotation=rotation)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.data.materials.append(material)
|
||||
return obj
|
||||
|
||||
|
||||
def add_uv_sphere(name: str, location: tuple[float, float, float], scale: tuple[float, float, float], material: bpy.types.Material) -> bpy.types.Object:
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=32, ring_count=16, location=location)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.scale = scale
|
||||
obj.data.materials.append(material)
|
||||
return obj
|
||||
|
||||
|
||||
def add_curve_pipe(name: str, points: list[tuple[float, float, float]], material: bpy.types.Material, bevel_depth: float = 0.035) -> bpy.types.Object:
|
||||
curve = bpy.data.curves.new(name, "CURVE")
|
||||
curve.dimensions = "3D"
|
||||
curve.resolution_u = 3
|
||||
curve.bevel_depth = bevel_depth
|
||||
curve.bevel_resolution = 3
|
||||
spl = curve.splines.new("POLY")
|
||||
spl.points.add(len(points) - 1)
|
||||
for point, coords in zip(spl.points, points):
|
||||
point.co = (coords[0], coords[1], coords[2], 1.0)
|
||||
obj = bpy.data.objects.new(name, curve)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
obj.data.materials.append(material)
|
||||
return obj
|
||||
|
||||
|
||||
def build_service_ring_shell(module: dict[str, Any], materials: dict[str, bpy.types.Material]) -> None:
|
||||
p = module["parameters"]
|
||||
material = materials[module["material_id"]]
|
||||
length = float(p.get("length", 18.0))
|
||||
radius = float(p.get("radius", 4.2))
|
||||
rib_count = int(p.get("rib_count", 18))
|
||||
# A readable maquette: curved ceiling ribs plus a flat service deck. Full
|
||||
# boolean shell carving can arrive when Geometry Script takes over.
|
||||
add_cube("service_lane_deck", (0, length / 2 - 1.0, 0.0), (6.4, length, 0.12), material)
|
||||
add_cube("left_shell_wall", (-3.25, length / 2 - 1.0, 1.65), (0.18, length, 3.2), material)
|
||||
add_cube("right_shell_wall", (3.25, length / 2 - 1.0, 1.65), (0.18, length, 3.2), material)
|
||||
for index in range(rib_count):
|
||||
y = -1.0 + index * (length / max(rib_count - 1, 1))
|
||||
add_curve_pipe(
|
||||
f"shell_rib_{index:02d}",
|
||||
[(-3.2, y, 0.35), (-2.4, y, 2.6), (0.0, y, radius), (2.4, y, 2.6), (3.2, y, 0.35)],
|
||||
material,
|
||||
bevel_depth=0.045,
|
||||
)
|
||||
add_curve_pipe("overhead_service_spine", [(0.0, -1.0, 3.85), (0.0, length - 1.0, 3.85)], material, bevel_depth=0.10)
|
||||
|
||||
|
||||
def build_crawl_throat(module: dict[str, Any], materials: dict[str, bpy.types.Material]) -> None:
|
||||
p = module["parameters"]
|
||||
material = materials[module["material_id"]]
|
||||
pos = tuple(p.get("position", [0, 8.2, 1.15]))
|
||||
add_cylinder("kappa_crawl_throat_dark_oval", pos, float(p.get("radius", 1.15)), float(p.get("depth", 0.35)), material, rotation=(math.pi / 2, 0, 0))
|
||||
add_cylinder("kappa_crawl_throat_gasket", (pos[0], pos[1] - 0.02, pos[2]), float(p.get("radius", 1.28)), 0.08, material, rotation=(math.pi / 2, 0, 0))
|
||||
|
||||
|
||||
def build_manifold_wall(module: dict[str, Any], materials: dict[str, bpy.types.Material]) -> None:
|
||||
p = module["parameters"]
|
||||
material = materials[module["material_id"]]
|
||||
x, y, z = p.get("position", [-3.0, 1.0, 1.25])
|
||||
length = float(p.get("length", 11.0))
|
||||
pipe_count = int(p.get("pipe_count", 8))
|
||||
panel_count = int(p.get("panel_count", 6))
|
||||
for index in range(pipe_count):
|
||||
yy = y + index * length / max(pipe_count - 1, 1)
|
||||
add_curve_pipe(f"manifold_pipe_{index:02d}", [(x, yy, 0.65), (x, yy, 2.35)], material, bevel_depth=0.035)
|
||||
for index in range(panel_count):
|
||||
yy = y + 0.5 + index * (length - 1.0) / max(panel_count - 1, 1)
|
||||
add_cube(f"manifold_panel_{index:02d}", (x + 0.05, yy, z + 0.25 * (index % 3)), (0.08, 0.65, 0.42), material)
|
||||
add_cylinder(f"manifold_valve_{index:02d}", (x + 0.12, yy + 0.38, z), 0.14, 0.05, material, vertices=24, rotation=(0, math.pi / 2, 0))
|
||||
|
||||
|
||||
def build_anchor_rail(module: dict[str, Any], materials: dict[str, bpy.types.Material]) -> None:
|
||||
p = module["parameters"]
|
||||
material = materials[module["material_id"]]
|
||||
x, y, z = p.get("position", [2.9, 0.0, 0.55])
|
||||
length = float(p.get("length", 12.0))
|
||||
hook_count = int(p.get("hook_count", 5))
|
||||
add_cube("baseline_anchor_rail_track", (x, y + length / 2, z), (0.16, length, 0.14), material)
|
||||
for index in range(hook_count):
|
||||
yy = y + 0.8 + index * (length - 1.6) / max(hook_count - 1, 1)
|
||||
add_cylinder(f"anchor_hook_{index:02d}", (x, yy, z + 0.35), 0.12, 0.42, material, vertices=16, rotation=(math.pi / 2, 0, 0))
|
||||
add_curve_pipe(f"anchor_tether_{index:02d}", [(x, yy, z + 0.45), (x - 0.4, yy, z + 0.75)], material, bevel_depth=0.018)
|
||||
|
||||
|
||||
def build_supervisor_catwalk(module: dict[str, Any], materials: dict[str, bpy.types.Material]) -> None:
|
||||
p = module["parameters"]
|
||||
material = materials[module["material_id"]]
|
||||
x, y, z = p.get("position", [3.1, 3.2, 2.75])
|
||||
length = float(p.get("length", 6.5))
|
||||
width = float(p.get("width", 1.5))
|
||||
rail_height = float(p.get("rail_height", 0.9))
|
||||
add_cube("supervisor_catwalk_deck", (x, y, z), (width, length, 0.12), material)
|
||||
add_cube("supervisor_catwalk_rail_top", (x - width / 2, y, z + rail_height), (0.08, length, 0.08), material)
|
||||
add_cube("supervisor_catwalk_rail_lower", (x - width / 2, y, z + rail_height * 0.5), (0.05, length, 0.05), material)
|
||||
for index in range(5):
|
||||
yy = y - length / 2 + index * length / 4
|
||||
add_cube(f"supervisor_screen_{index:02d}", (x + 0.05, yy, z + 1.35), (0.06, 0.75, 0.5), material)
|
||||
|
||||
|
||||
def build_cephalopod_work_support_station(module: dict[str, Any], materials: dict[str, bpy.types.Material]) -> None:
|
||||
p = module["parameters"]
|
||||
material = materials[module["material_id"]]
|
||||
x, y, z = p.get("position", [1.8, 4.7, 0.95])
|
||||
width = float(p.get("width", 2.5))
|
||||
depth = float(p.get("depth", 1.6))
|
||||
height = float(p.get("height", 1.2))
|
||||
add_cube("teth_work_support_open_frame", (x, y, z), (width, depth, 0.10), material)
|
||||
for sx in (-1, 1):
|
||||
add_curve_pipe(f"teth_support_loop_{sx}", [(x + sx * width / 2, y - depth / 2, z), (x + sx * width / 2, y, z + height), (x + sx * width / 2, y + depth / 2, z)], material, bevel_depth=0.025)
|
||||
for index in range(5):
|
||||
angle = (index / 5.0) * math.tau
|
||||
add_curve_pipe(
|
||||
f"teth_tool_rail_{index:02d}",
|
||||
[(x, y, z + 0.25), (x + math.cos(angle) * width * 0.45, y + math.sin(angle) * depth * 0.45, z + 0.55)],
|
||||
material,
|
||||
bevel_depth=0.018,
|
||||
)
|
||||
add_curve_pipe("teth_oxygenation_loop", [(x - 0.8, y - 0.75, z + 0.25), (x, y, z + 1.0), (x + 0.8, y - 0.75, z + 0.25)], material, bevel_depth=0.028)
|
||||
|
||||
|
||||
MODULE_BUILDERS = {
|
||||
"service_ring_shell": build_service_ring_shell,
|
||||
"crawl_throat": build_crawl_throat,
|
||||
"manifold_wall": build_manifold_wall,
|
||||
"anchor_rail": build_anchor_rail,
|
||||
"supervisor_catwalk": build_supervisor_catwalk,
|
||||
"cephalopod_work_support_station": build_cephalopod_work_support_station,
|
||||
}
|
||||
|
||||
|
||||
def add_humanoid_proxy(actor: dict[str, Any], material: bpy.types.Material) -> None:
|
||||
x, y, z = actor["default_position"]
|
||||
sx, sy, sz = actor["scale"]
|
||||
obj = add_uv_sphere(actor["actor_id"], (x, y, z + sz / 2), (sx, sy, sz / 2), material)
|
||||
obj.rotation_euler = tuple(actor["default_rotation"])
|
||||
if "slate" in actor["proxy_shape"]:
|
||||
add_cube(f"{actor['actor_id']}_slate", (x + 0.25, y, z + 1.0), (0.35, 0.04, 0.25), material)
|
||||
if "hook" in actor["proxy_shape"]:
|
||||
add_curve_pipe(f"{actor['actor_id']}_hook", [(x, y, z + 0.8), (x + 0.45, y, z + 0.65)], material, bevel_depth=0.035)
|
||||
|
||||
|
||||
def add_cephalopod_proxy(actor: dict[str, Any], material: bpy.types.Material) -> None:
|
||||
x, y, z = actor["default_position"]
|
||||
sx, sy, sz = actor["scale"]
|
||||
add_uv_sphere(actor["actor_id"], (x, y, z + 0.25), (sx * 0.45, sy * 0.45, sz * 0.45), material)
|
||||
for index in range(8):
|
||||
angle = index * math.tau / 8
|
||||
add_curve_pipe(
|
||||
f"{actor['actor_id']}_tentacle_{index:02d}",
|
||||
[
|
||||
(x, y, z + 0.22),
|
||||
(x + math.cos(angle) * sx * 0.45, y + math.sin(angle) * sy * 0.45, z + 0.15),
|
||||
(x + math.cos(angle) * sx * 0.75, y + math.sin(angle) * sy * 0.75, z + 0.08),
|
||||
],
|
||||
material,
|
||||
bevel_depth=0.035,
|
||||
)
|
||||
|
||||
|
||||
def build_proxy_actors(plan: dict[str, Any], materials: dict[str, bpy.types.Material]) -> None:
|
||||
for actor in plan.get("proxy_actors", []):
|
||||
material = materials[actor["material_id"]]
|
||||
if "cephalopod" in actor["proxy_shape"]:
|
||||
add_cephalopod_proxy(actor, material)
|
||||
else:
|
||||
add_humanoid_proxy(actor, material)
|
||||
|
||||
|
||||
def build_cameras(plan: dict[str, Any]) -> None:
|
||||
for camera in plan["cameras"]:
|
||||
bpy.ops.object.camera_add(location=tuple(camera["position"]), rotation=tuple(camera["rotation"]))
|
||||
obj = bpy.context.object
|
||||
obj.name = camera["camera_id"]
|
||||
if "target" in camera:
|
||||
direction = Vector(tuple(camera["target"])) - obj.location
|
||||
obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
|
||||
obj.data.lens = float(camera["lens_mm"])
|
||||
obj["visual_scene_ids"] = ",".join(camera["visual_scene_ids"])
|
||||
obj["shot_role"] = camera["shot_role"]
|
||||
obj["framing_notes"] = camera["framing_notes"]
|
||||
|
||||
|
||||
def add_lights() -> None:
|
||||
bpy.ops.object.light_add(type="AREA", location=(0, 2.0, 4.2))
|
||||
light = bpy.context.object
|
||||
light.name = "soft_blockout_overhead_light"
|
||||
light.data.energy = 450
|
||||
light.data.size = 5.0
|
||||
bpy.ops.object.light_add(type="POINT", location=(-2.2, 4.0, 1.8))
|
||||
amber = bpy.context.object
|
||||
amber.name = "amber_manifold_guide_light"
|
||||
amber.data.energy = 160
|
||||
amber.data.color = (1.0, 0.55, 0.15)
|
||||
bpy.ops.object.light_add(type="POINT", location=(3.2, 3.2, 3.4))
|
||||
blue = bpy.context.object
|
||||
blue.name = "blue_supervisor_guide_light"
|
||||
blue.data.energy = 140
|
||||
blue.data.color = (0.25, 0.45, 1.0)
|
||||
|
||||
|
||||
def configure_scene(resolution_x: int, resolution_y: int) -> None:
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "BLENDER_WORKBENCH"
|
||||
scene.display.shading.light = "STUDIO"
|
||||
scene.display.shading.color_type = "MATERIAL"
|
||||
scene.render.resolution_x = resolution_x
|
||||
scene.render.resolution_y = resolution_y
|
||||
scene.view_settings.view_transform = "Standard"
|
||||
scene.view_settings.look = "Medium High Contrast"
|
||||
|
||||
|
||||
def build_scene(plan: dict[str, Any], resolution_x: int, resolution_y: int) -> None:
|
||||
clear_scene()
|
||||
materials = material_map(plan)
|
||||
for module in plan["geometry_modules"]:
|
||||
builder = MODULE_BUILDERS.get(module["geometry_script_tree"])
|
||||
if not builder:
|
||||
print(f"warning: no builder for {module['geometry_script_tree']}")
|
||||
continue
|
||||
builder(module, materials)
|
||||
build_proxy_actors(plan, materials)
|
||||
build_cameras(plan)
|
||||
add_lights()
|
||||
configure_scene(resolution_x, resolution_y)
|
||||
|
||||
|
||||
def save_blend(plan: dict[str, Any], output_blend: Path | None) -> None:
|
||||
target = output_blend or resolve_project_path(plan["layout"]["layout_asset"])
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(target))
|
||||
print(f"wrote {target}")
|
||||
|
||||
|
||||
def render_guides(plan: dict[str, Any], output_dir: Path | None) -> None:
|
||||
render_root = output_dir or ROOT
|
||||
cameras = {obj.name: obj for obj in bpy.data.objects if obj.type == "CAMERA"}
|
||||
for render in plan["guide_renders"]:
|
||||
camera = cameras.get(render["camera_id"])
|
||||
if not camera:
|
||||
print(f"warning: missing camera {render['camera_id']} for {render['render_id']}")
|
||||
continue
|
||||
output_ref = resolve_project_path(render["output_refs"][0])
|
||||
if output_dir:
|
||||
output_ref = output_dir / Path(render["output_refs"][0]).name
|
||||
output_ref.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.context.scene.camera = camera
|
||||
bpy.context.scene.render.filepath = str(output_ref)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
print(f"wrote {output_ref}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_script_args()
|
||||
plan = load_plan(args.plan)
|
||||
build_scene(plan, args.resolution_x, args.resolution_y)
|
||||
if args.save_blend:
|
||||
save_blend(plan, args.output_blend)
|
||||
if args.render_guides:
|
||||
render_guides(plan, args.output_dir)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
34
scripts/run_blender_blockout.py
Normal file
34
scripts/run_blender_blockout.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Run Blender blockout generation with a stable local Blender path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_BLENDER = Path(r"C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe")
|
||||
BUILDER = ROOT / "scripts" / "blender" / "build_visual_blockout.py"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--blender", type=Path, default=DEFAULT_BLENDER)
|
||||
args, builder_args = parser.parse_known_args()
|
||||
|
||||
blender = args.blender
|
||||
if not blender.exists():
|
||||
raise SystemExit(f"Blender executable not found: {blender}")
|
||||
if builder_args and builder_args[0] == "--":
|
||||
builder_args = builder_args[1:]
|
||||
if not builder_args:
|
||||
raise SystemExit("Pass build args after --, including --plan.")
|
||||
|
||||
command = [str(blender), "--background", "--python", str(BUILDER), "--", *builder_args]
|
||||
print("running:", " ".join(f'\"{part}\"' if " " in part else part for part in command))
|
||||
return subprocess.call(command, cwd=ROOT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -64,3 +64,4 @@
|
|||
{"ts": "2026-05-03T12:14:28+00:00", "type": "visual-doctrine", "status": "ok", "note": "Generalized visual reviewer guardrails for unnamed drawable groups and stale lore terminology, removed Pallas-specific policing from the reviewer, and revised the Pallas fixture to replace stale dry-side language with anchor-rail/baseline-anchor work-zone language plus concrete group descriptions."}
|
||||
{"ts": "2026-05-03T12:34:07+00:00", "type": "visual-doctrine", "status": "ok", "note": "Revised Pallas visual prompts to use cephalopod-specific tentacle/mantle/support-harness anatomy instead of human-limb shorthand, replaced supervisor-glass staging with open catwalk and transparent safety-rail geometry, and added generalized reviewer checks for nonhuman anatomy humanization. BFL FLUX.2 Max spot-check with simplified prompt and prompt upsampling disabled still dropped Teth and missed the Dreams-style target, so BFL remains unsuitable for complex ensemble IF frames without further proof."}
|
||||
{"ts": "2026-05-03T12:55:07+00:00", "type": "visual-doctrine", "status": "ok", "note": "Finished Pallas visual prompt cleanup after imagegen spot check: Teth now uses affirmative cephalopod work-support rig language, padded contact bands, support loops, humidity rings, oxygenation, and tool access instead of containment-shaped harness wording. Added generalized reviewer guardrail for assistive/workplace equipment object-class drift. Did not add scene-geometry continuity guardrails; multi-angle continuity should be handled by future Blender/blockout guide assets, not prompt policing."}
|
||||
{"ts": "2026-05-03T13:33:16+00:00", "type": "visual-blockout", "status": "ok", "note": "Added first visual blockout plan seam for illustrated IF: Pallas Service Ring Kappa now has a validated ghostlight.visual_blockout_plan.v0 artifact, Blender wrapper for the Steam install, fully scripted low-poly blockout generator, and ignored guide-render outputs. The blockout is a spatial/camera reference, not a segmentation mask; Geometry Script/Geometry Nodes can replace individual procedural modules via geometry_script_tree handles."}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ current_artifacts:
|
|||
- docs/architecture/soft-model-training-artifacts.md
|
||||
- docs/architecture/training-plan.md
|
||||
- docs/architecture/technology-item-manifest-plan.md
|
||||
- docs/architecture/visual-blockout-architecture.md
|
||||
schemas:
|
||||
- schemas/agent-state.schema.json
|
||||
- schemas/projection-example.schema.json
|
||||
|
|
@ -127,6 +128,7 @@ current_artifacts:
|
|||
- schemas/coordinator-artifact.schema.json
|
||||
- schemas/responder-packet.schema.json
|
||||
- schemas/responder-output.schema.json
|
||||
- schemas/visual-blockout-plan.schema.json
|
||||
prompts:
|
||||
- prompts/README.md
|
||||
- prompts/sandboxed-responder-packet.md
|
||||
|
|
@ -172,6 +174,7 @@ current_artifacts:
|
|||
- examples/ink/pallas-species-strikes.branch-and-fold.v0.ink
|
||||
- examples/ink/pallas-species-strikes.branch-and-fold.v0.training.json
|
||||
- examples/visual/pallas-species-strikes.branch-and-fold.v0.visual.json
|
||||
- examples/visual-blockouts/pallas-species-strikes.service-ring-kappa.v0.blockout.json
|
||||
- experiments/pallas-species-strikes/pallas-species-strikes.branch-and-fold-clean-run.v0.md
|
||||
archived_receipts:
|
||||
- Archived local-model prototype captures remain under experiments/ and examples/ink/ for validation and lessons, not active steering.
|
||||
|
|
@ -186,5 +189,5 @@ data_flow:
|
|||
- If state grows faster than understanding, cut it back.
|
||||
|
||||
current_status:
|
||||
summary: Pallas Species Strikes now has a reviewer-revised accepted-as-draft branch-and-fold IF fixture grounded in AU Bloom cavity-shell labor, Awakened Labor organizing, BioElevate uplift/biodrone systems, baseline displacement fear, and cephalopod maintenance leverage. Its lore digest and visual plan treat Bloom yards as engineered rotating habitat shells built from consolidated asteroid rubble/aggregate, TCS, shell plating, supports, seals, manifolds, and service systems. Its training annotation now references a separate visual plan artifact for click-through visual segmentation, stable visual character refs, the global Dreams-style image cue, and affirmative imagegen-ready scene prompts. Cold Wake remains an archived historical fixture and local-model receipt surface, not the active gold-data path.
|
||||
next_action: Add the corpus coverage ledger seam or select the next Aetheria fixture from the 100-150 broad coverage target.
|
||||
summary: Pallas Species Strikes now has a reviewer-revised accepted-as-draft branch-and-fold IF fixture grounded in AU Bloom cavity-shell labor, Awakened Labor organizing, BioElevate uplift/biodrone systems, baseline displacement fear, and cephalopod maintenance leverage. Its visual stack now separates visual prompt planning from a Blender-backed blockout plan; the blockout plan is a spatial/camera maquette for multi-angle illustrated replay, not a segmentation mask or hidden color contract. Cold Wake remains an archived historical fixture and local-model receipt surface, not the active gold-data path.
|
||||
next_action: Promote the blockout seam only as far as needed for the Pallas fixture, then add the corpus coverage ledger seam or select the next Aetheria fixture from the 100-150 broad coverage target.
|
||||
|
|
|
|||
176
tools/validate_visual_blockouts.py
Normal file
176
tools/validate_visual_blockouts.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Validate Ghostlight visual blockout plan examples."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_EXAMPLES = sorted((ROOT / "examples" / "visual-blockouts").glob("*.json"))
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValidationError(message)
|
||||
|
||||
|
||||
def require_keys(obj: dict[str, Any], keys: list[str], path: str) -> None:
|
||||
missing = [key for key in keys if key not in obj]
|
||||
require(not missing, f"{path} missing required keys: {', '.join(missing)}")
|
||||
|
||||
|
||||
def require_nonempty_string(value: Any, path: str) -> None:
|
||||
require(isinstance(value, str) and value.strip(), f"{path} must be a non-empty string")
|
||||
|
||||
|
||||
def require_string_list(value: Any, path: str) -> None:
|
||||
require(isinstance(value, list), f"{path} must be an array")
|
||||
for index, item in enumerate(value):
|
||||
require_nonempty_string(item, f"{path}[{index}]")
|
||||
|
||||
|
||||
def require_vector3(value: Any, path: str) -> None:
|
||||
require(isinstance(value, list) and len(value) == 3, f"{path} must be a 3-number array")
|
||||
for index, item in enumerate(value):
|
||||
require(isinstance(item, (int, float)), f"{path}[{index}] must be a number")
|
||||
|
||||
|
||||
def require_color(value: Any, path: str) -> None:
|
||||
require(isinstance(value, list) and len(value) == 4, f"{path} must be a 4-number RGBA array")
|
||||
for index, item in enumerate(value):
|
||||
require(isinstance(item, (int, float)) and 0 <= item <= 1, f"{path}[{index}] must be 0..1")
|
||||
|
||||
|
||||
def validate_blockout_plan(document: dict[str, Any], source: Path) -> None:
|
||||
require(document.get("schema_version") == "ghostlight.visual_blockout_plan.v0", f"{source}.schema_version is wrong")
|
||||
require_keys(
|
||||
document,
|
||||
[
|
||||
"blockout_plan_id",
|
||||
"fixture_id",
|
||||
"visual_plan_ref",
|
||||
"purpose",
|
||||
"layout",
|
||||
"semantic_materials",
|
||||
"geometry_modules",
|
||||
"proxy_actors",
|
||||
"cameras",
|
||||
"guide_renders",
|
||||
"prompt_use_contract",
|
||||
],
|
||||
str(source),
|
||||
)
|
||||
for key in ["blockout_plan_id", "fixture_id", "visual_plan_ref", "purpose"]:
|
||||
require_nonempty_string(document[key], f"{source}.{key}")
|
||||
|
||||
visual_plan = ROOT / document["visual_plan_ref"]
|
||||
require(visual_plan.exists(), f"{source}.visual_plan_ref does not exist: {visual_plan}")
|
||||
visual_data = load_json(visual_plan)
|
||||
visual_scene_ids = {
|
||||
scene.get("visual_scene_id")
|
||||
for scene in visual_data.get("visual_scene_plan", {}).get("scenes", [])
|
||||
if isinstance(scene, dict)
|
||||
}
|
||||
require(visual_scene_ids, f"{source}.visual_plan_ref has no visual_scene_plan.scenes")
|
||||
|
||||
layout = document["layout"]
|
||||
require_keys(layout, ["layout_id", "layout_asset", "coordinate_system", "scale_notes", "source_refs"], f"{source}.layout")
|
||||
for key in ["layout_id", "layout_asset", "coordinate_system", "scale_notes"]:
|
||||
require_nonempty_string(layout[key], f"{source}.layout.{key}")
|
||||
require_string_list(layout["source_refs"], f"{source}.layout.source_refs")
|
||||
|
||||
material_ids: set[str] = set()
|
||||
require(isinstance(document["semantic_materials"], list) and document["semantic_materials"], f"{source}.semantic_materials must be non-empty")
|
||||
for index, material in enumerate(document["semantic_materials"]):
|
||||
path = f"{source}.semantic_materials[{index}]"
|
||||
require_keys(material, ["material_id", "display_name", "base_color", "intended_read", "use_in_prompt"], path)
|
||||
require_nonempty_string(material["material_id"], f"{path}.material_id")
|
||||
require(material["material_id"] not in material_ids, f"{path}.material_id duplicates {material['material_id']}")
|
||||
material_ids.add(material["material_id"])
|
||||
require_color(material["base_color"], f"{path}.base_color")
|
||||
for key in ["display_name", "intended_read", "use_in_prompt"]:
|
||||
require_nonempty_string(material[key], f"{path}.{key}")
|
||||
|
||||
module_ids: set[str] = set()
|
||||
for index, module in enumerate(document["geometry_modules"]):
|
||||
path = f"{source}.geometry_modules[{index}]"
|
||||
require_keys(module, ["module_id", "object_class", "script_ref", "geometry_script_tree", "parameters", "material_id", "notes"], path)
|
||||
require_nonempty_string(module["module_id"], f"{path}.module_id")
|
||||
require(module["module_id"] not in module_ids, f"{path}.module_id duplicates {module['module_id']}")
|
||||
module_ids.add(module["module_id"])
|
||||
require(module["material_id"] in material_ids, f"{path}.material_id references unknown material {module['material_id']}")
|
||||
require(isinstance(module["parameters"], dict), f"{path}.parameters must be an object")
|
||||
for key in ["object_class", "script_ref", "geometry_script_tree", "notes"]:
|
||||
require_nonempty_string(module[key], f"{path}.{key}")
|
||||
|
||||
for index, actor in enumerate(document["proxy_actors"]):
|
||||
path = f"{source}.proxy_actors[{index}]"
|
||||
require_keys(actor, ["actor_id", "visual_ref", "proxy_shape", "material_id", "default_position", "default_rotation", "scale", "notes"], path)
|
||||
require(actor["material_id"] in material_ids, f"{path}.material_id references unknown material {actor['material_id']}")
|
||||
for key in ["actor_id", "visual_ref", "proxy_shape", "notes"]:
|
||||
require_nonempty_string(actor[key], f"{path}.{key}")
|
||||
require_vector3(actor["default_position"], f"{path}.default_position")
|
||||
require_vector3(actor["default_rotation"], f"{path}.default_rotation")
|
||||
require_vector3(actor["scale"], f"{path}.scale")
|
||||
|
||||
camera_ids: set[str] = set()
|
||||
for index, camera in enumerate(document["cameras"]):
|
||||
path = f"{source}.cameras[{index}]"
|
||||
require_keys(camera, ["camera_id", "visual_scene_ids", "position", "rotation", "lens_mm", "shot_role", "framing_notes"], path)
|
||||
require_nonempty_string(camera["camera_id"], f"{path}.camera_id")
|
||||
require(camera["camera_id"] not in camera_ids, f"{path}.camera_id duplicates {camera['camera_id']}")
|
||||
camera_ids.add(camera["camera_id"])
|
||||
require_string_list(camera["visual_scene_ids"], f"{path}.visual_scene_ids")
|
||||
for scene_id in camera["visual_scene_ids"]:
|
||||
require(scene_id in visual_scene_ids, f"{path}.visual_scene_ids references unknown visual scene {scene_id}")
|
||||
require_vector3(camera["position"], f"{path}.position")
|
||||
require_vector3(camera["rotation"], f"{path}.rotation")
|
||||
if "target" in camera:
|
||||
require_vector3(camera["target"], f"{path}.target")
|
||||
require(isinstance(camera["lens_mm"], (int, float)) and camera["lens_mm"] > 0, f"{path}.lens_mm must be positive")
|
||||
for key in ["shot_role", "framing_notes"]:
|
||||
require_nonempty_string(camera[key], f"{path}.{key}")
|
||||
|
||||
for index, render in enumerate(document["guide_renders"]):
|
||||
path = f"{source}.guide_renders[{index}]"
|
||||
require_keys(render, ["render_id", "camera_id", "visual_scene_id", "render_modes", "output_refs", "imagegen_reference_use"], path)
|
||||
require(render["camera_id"] in camera_ids, f"{path}.camera_id references unknown camera {render['camera_id']}")
|
||||
require(render["visual_scene_id"] in visual_scene_ids, f"{path}.visual_scene_id references unknown visual scene {render['visual_scene_id']}")
|
||||
for key in ["render_id", "imagegen_reference_use"]:
|
||||
require_nonempty_string(render[key], f"{path}.{key}")
|
||||
require_string_list(render["render_modes"], f"{path}.render_modes")
|
||||
require_string_list(render["output_refs"], f"{path}.output_refs")
|
||||
|
||||
contract = document["prompt_use_contract"]
|
||||
require_keys(contract, ["reference_image_role", "must_preserve", "may_reinterpret", "must_not_assume"], f"{source}.prompt_use_contract")
|
||||
require_nonempty_string(contract["reference_image_role"], f"{source}.prompt_use_contract.reference_image_role")
|
||||
for key in ["must_preserve", "may_reinterpret", "must_not_assume"]:
|
||||
require_string_list(contract[key], f"{source}.prompt_use_contract.{key}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("paths", nargs="*", type=Path, help="Visual blockout plan JSON files to validate.")
|
||||
args = parser.parse_args()
|
||||
paths = args.paths or DEFAULT_EXAMPLES
|
||||
require(bool(paths), "No visual blockout plans found.")
|
||||
for path in paths:
|
||||
validate_blockout_plan(load_json(path), path)
|
||||
print(f"ok: {path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue