Add visual blockout pipeline

This commit is contained in:
Meta Vens 2026-05-03 14:34:50 +01:00
commit 139902246e
15 changed files with 1447 additions and 8 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ __pycache__/
node_modules/
state/scratch-compaction-*.md
experiments/images/
experiments/blender/

View 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.

View file

@ -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"
]
}
}

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View 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
View 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.

View 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())

View 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())

View file

@ -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."}

View file

@ -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.

View 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())