From 139902246e740f968f690186f2252dc80dfd2d9d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 3 May 2026 14:34:50 +0100 Subject: [PATCH] Add visual blockout pipeline --- .gitignore | 1 + .../visual-blockout-architecture.md | 93 +++ ...trikes.service-ring-kappa.v0.blockout.json | 556 ++++++++++++++++++ ...ies-strikes.branch-and-fold.v0.visual.json | 1 + notes/fresh-workspace-handoff.md | 16 +- notes/ghostlight-current-system-map.md | 12 +- notes/ghostlight-implementation-plan.md | 6 + package.json | 6 +- schemas/visual-blockout-plan.schema.json | 156 +++++ scripts/blender/README.md | 31 + scripts/blender/build_visual_blockout.py | 359 +++++++++++ scripts/run_blender_blockout.py | 34 ++ state/evidence.jsonl | 1 + state/map.yaml | 7 +- tools/validate_visual_blockouts.py | 176 ++++++ 15 files changed, 1447 insertions(+), 8 deletions(-) create mode 100644 docs/architecture/visual-blockout-architecture.md create mode 100644 examples/visual-blockouts/pallas-species-strikes.service-ring-kappa.v0.blockout.json create mode 100644 schemas/visual-blockout-plan.schema.json create mode 100644 scripts/blender/README.md create mode 100644 scripts/blender/build_visual_blockout.py create mode 100644 scripts/run_blender_blockout.py create mode 100644 tools/validate_visual_blockouts.py diff --git a/.gitignore b/.gitignore index d772650..ea0d2c9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ node_modules/ state/scratch-compaction-*.md experiments/images/ +experiments/blender/ diff --git a/docs/architecture/visual-blockout-architecture.md b/docs/architecture/visual-blockout-architecture.md new file mode 100644 index 0000000..886a22d --- /dev/null +++ b/docs/architecture/visual-blockout-architecture.md @@ -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. diff --git a/examples/visual-blockouts/pallas-species-strikes.service-ring-kappa.v0.blockout.json b/examples/visual-blockouts/pallas-species-strikes.service-ring-kappa.v0.blockout.json new file mode 100644 index 0000000..c69a8ba --- /dev/null +++ b/examples/visual-blockouts/pallas-species-strikes.service-ring-kappa.v0.blockout.json @@ -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" + ] + } +} diff --git a/examples/visual/pallas-species-strikes.branch-and-fold.v0.visual.json b/examples/visual/pallas-species-strikes.branch-and-fold.v0.visual.json index aef64fe..06cb3b3 100644 --- a/examples/visual/pallas-species-strikes.branch-and-fold.v0.visual.json +++ b/examples/visual/pallas-species-strikes.branch-and-fold.v0.visual.json @@ -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", diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 606ef16..e860dd7 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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 diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index e006132..46729f5 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -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 diff --git a/notes/ghostlight-implementation-plan.md b/notes/ghostlight-implementation-plan.md index 375cac8..2652116 100644 --- a/notes/ghostlight-implementation-plan.md +++ b/notes/ghostlight-implementation-plan.md @@ -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 diff --git a/package.json b/package.json index 4cbf17d..f6dbf9c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/schemas/visual-blockout-plan.schema.json b/schemas/visual-blockout-plan.schema.json new file mode 100644 index 0000000..02adb91 --- /dev/null +++ b/schemas/visual-blockout-plan.schema.json @@ -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" } + } + } +} diff --git a/scripts/blender/README.md b/scripts/blender/README.md new file mode 100644 index 0000000..cff46fc --- /dev/null +++ b/scripts/blender/README.md @@ -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. diff --git a/scripts/blender/build_visual_blockout.py b/scripts/blender/build_visual_blockout.py new file mode 100644 index 0000000..de74d8f --- /dev/null +++ b/scripts/blender/build_visual_blockout.py @@ -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()) diff --git a/scripts/run_blender_blockout.py b/scripts/run_blender_blockout.py new file mode 100644 index 0000000..a1bde35 --- /dev/null +++ b/scripts/run_blender_blockout.py @@ -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()) diff --git a/state/evidence.jsonl b/state/evidence.jsonl index c793225..5bbaebf 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/map.yaml b/state/map.yaml index ce91700..bf5bda1 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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. diff --git a/tools/validate_visual_blockouts.py b/tools/validate_visual_blockouts.py new file mode 100644 index 0000000..2b92671 --- /dev/null +++ b/tools/validate_visual_blockouts.py @@ -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())