Reading prompt from stdin...
OpenAI Codex v0.161.0
--------
workdir: /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
model: gpt-6-astra
provider: openai
approval: never
sandbox: danger-full-access
reasoning effort: xhigh
reasoning summaries: none
session id: 01a14603-7093-7592-8377-63f2aca047f5
--------
user
Build realistic near-future military space interceptor (hard science fiction, real spaceflight engineering): a slim armoured fuselage with a two-seat tandem cockpit under a small faceted armoured canopy, one big fusion main engine with a bell nozzle at the rear, four RCS thruster quads, folding heat-radiator panels along the sides, a nose railgun barrel, sensor blisters, cylindrical propellant tanks; no wings (there is no air in space) (the one on sheet.jpg) as a production, REALISTIC 3D game asset in Blender, for Forge's asset library. Look at the reference
images in this directory with your image tool first: sheet.jpg. It must look like a real object from a modern
third-person game up close: never blocky, never toy-like, never flat colours.

Blender 5.2 is headless on another machine. Write bpy scripts here and run them with:
  ssh gex44 /root/blender-spike/blender-5.2.0-linux-x64/blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/<script>.py
(first `ssh gex44 mkdir -p /root/fal-author/interceptor-hardsf-v1`, scp scripts and any textures to gex44:/root/fal-author/interceptor-hardsf-v1/, scp renders back here, then
LOOK at them with your image tool). Iterate until the renders match the references: silhouette, proportions, every
named part, materials. Only one Blender job on the GPU box at a time from you.

Quality bar:
- Real dimensions in metres: 16.0 m long, 6.5 m wide, 4.2 m high.
- Construction detail as geometry where it shapes the silhouette or catches light: bevelled edges everywhere (no razor
  box edges), real thickness, frames around openings, trims, rails, fittings, bolts, ropes, cables, small parts. No
  z-fighting, no gaps, nothing floating, smooth shading on round parts.
- Photographic materials: use CC0 Poly Haven textures (download 1k or 2k jpg maps from https://dl.polyhaven.org/file/
  ph-assets/Textures/jpg/<res>/<id>/<id>_<map>_<res>.jpg, maps diff, nor_gl, arm; pick ids by browsing
  https://api.polyhaven.com/assets?t=textures) mixed with node detail (rust streaks, grime, moss, edge wear, salt), then
  BAKE each material's result with Cycles to image textures (base colour, normal, ORM) so it survives glTF export.
  List every Poly Haven id you used in NOTES.md (licence evidence). No other image sources.
- Budget: 40k to 90k triangles, at most 9 materials, texture images at most 2048 px, GLB at most 12 MB.

Export contract (glTF binary .glb, Y up, metres): Nose toward +Z, up = +Y, +X = the ship's LEFT side, centred on X = 0; origin (0,0,0) at the ship's centre of mass (about the middle of the hull), the ship level and flying: landing gear, if the design has any, is RETRACTED with its doors closed. Apply transforms and modifiers. No cameras, lights or
helpers in the GLB. GAME WEIGHT overrides the budget above: every texture image at most 1024 px, ORM / occlusion maps at most 512 px; share baked texture sets between parts and use tiling materials + trim textures on big surfaces. Separate named mesh nodes: Hull (everything fixed to the hull). Every MOVING part is its own node with its object origin ON its pivot: Turret_1..Turret_n (each gun turret, origin on its vertical traverse axis, turns about its local Y) each with a child Gun_1..Gun_n (the barrels, origin on the elevation trunnion axis, elevates about X); Canopy (cockpit canopy, origin on its hinge) on craft with a canopy; and the extra moving parts named in the description. Empty nodes (no mesh): Thruster_1..Thruster_n at the centre of every main engine nozzle exit (games put flame and glow there; exhaust points to -Z), Muzzle_1..Muzzle_n at every barrel tip (child of its Gun, or of Hull for fixed guns), Hardpoint_1..n (missile and torpedo launch points), Seat_Pilot (pilot hip point) on craft with a cockpit, Camera_Chase (a good third-person chase camera point behind and above), Light_Nav_L (left tip, red), Light_Nav_R (right tip, green), Light_Beacon. Materials: the glowing inside of every engine nozzle = one material named "Engines" (emissive, colour as described, modest strength, games raise it); lit windows, running lights and glowing details = one material "Lamps" (emissive); cockpit or bridge glazing = one material "Glass" (alpha BLEND, tinted) with a visible cockpit (seat, panels, stick) behind it on small craft. In nodes.json add "axes": for every moving node, the unit axis it turns about in its parent's space. PAINT CONTRACT (how games get faction colours of one model): the main hull colour lives in ONE material named exactly "Paint". Its base colour TEXTURE holds only light neutral detail (near white, with panel lines, grey grime, engine scorch, micro-meteorite pitting and chipped edges baked in) and its baseColorFactor holds the paint colour; default factor = [0.55, 0.56, 0.55] (light grey). A game changes that factor, so nothing else (bare metal, engines, glass, lamps, weapons, cockpit) may use Paint. Extra moving parts: Radiator_L, Radiator_R (radiator panels, origin on their hinge, deployed). In nodes.json add "paints": six good paints for this model as linear RGB triples with names, the default first, in the spirit of: naval grey, gunmetal, olive drab, desert sand, arctic white, deep navy, black, crimson; and render paints.png: a 3x2 grid of the same 3/4 view in those six paints (EEVEE or low-sample Cycles) to prove the recolour works. MOVING PARTS: never apply transforms to a moving node. Check it in Blender before export: rotate each moving node 30 degrees in a test render; nothing may leave its place. No text, numbers, insignia, faction emblems, flags or logos anywhere (games add their own factions). ORIGINAL DESIGN: never model a famous film, TV, anime or game ship (no X-wing, TIE, Star Destroyer, Millennium Falcon, Enterprise, Serenity, Normandy, Viper, Valkyrie, Gundam, Halo, Homeworld, EVE, Elite or Expanse ship): if the sheet looks like one, keep its proportions and parts but make the silhouette details, nose and engines your own. RENDERS: final.png and paints.png on a dark space backdrop (near black with a faint blue gradient, no ground) lit by a strong white key light from the upper front left plus a soft cool fill, so every part reads. Keep this directory clean: put any helper tools you install (node_modules, vendored three.js, browser checks) under /tmp, never here. Pace: deliver a first complete GLB within about 30 minutes, then use what is left of about 60 minutes to improve what the renders show is weakest. Use Cycles with the GPU (OptiX/CUDA) and modest samples for bakes and renders. 
Deliver in this directory: interceptor-hardsf.glb; build_interceptor-hardsf.py (ONE script that builds the whole asset from an empty scene,
downloads/loads its textures, bakes and exports: the GLB you deliver must come from a clean run of it); final.png
(1920x1080 Cycles render, 3/4 front view, soft daylight); side.png (orthographic side); clay.png (same view as final,
plain grey material); nodes.json (every node name with world position + overall bounding box); NOTES.md (6 lines:
what you made, triangle count, materials, texture sizes, Poly Haven ids, what still differs from the references).
Stop when you judge it matches the references and would not look out of place in a modern realistic game.

warning: `--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation.
warning: `--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation.
codex
I’ll inspect the reference and Forge asset rules, then build, bake, and render the interceptor on the GPU machine. I’ll check the proportions, materials, and moving-part pivots before exporting the game asset.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "pwd && rg --files -g 'AGENTS.md' -g 'sheet.jpg' -g '*.png' -g '*.py' -g 'NOTES.md' . /home/claude-agent/forge-build 2>/dev/null | head -80" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
/home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
/home/claude-agent/forge-build/preseed-v4-20261008/v5-backup/build.py
/home/claude-agent/forge-build/preseed-v4-20261008/v6-backup/build.py
/home/claude-agent/forge-build/preseed-v4-20261008/v6-backup/harness_blocks.py
/home/claude-agent/forge-build/patch_factory.py
/home/claude-agent/forge-build/poll-run318.py
/home/claude-agent/forge-build/preseed-v4-20261008/site/v7_blocks.py
/home/claude-agent/forge-build/preseed-v4-20261008/site/build.py
/home/claude-agent/forge-build/preseed-v4-20261008/site/media/logos/jev.png
/home/claude-agent/forge-build/ultratex-cast-20261007/viewer/shoot.py
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/fail_chest_front_1.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_head_left.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_full_fr.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p1_crop.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_full_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/check.py
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_full_top.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p1_full_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_full_back.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/ref_chest_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_head_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_chest_back.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_full_left.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_head_right.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_head_back.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_full_right.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/rejected/p_head_left.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/rejected/p_chest_front_2.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/rejected/p_full_bottom.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/rejected/p_chest_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/rejected/p_head_left_2.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/rejected/p_full_bl.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/rejected/p_full_br.png
/home/claude-agent/forge-build/ultratex-cast-20261007/paint/p_full_fl.png
/home/claude-agent/forge-build/ultratex-cast-20261007/shots/walk-far.png
/home/claude-agent/forge-build/ultratex-cast-20261007/shots/idle-far.png
/home/claude-agent/forge-build/ultratex-cast-20261007/shots/idle-near.png
/home/claude-agent/forge-build/ultratex-cast-20261007/shots/talk-near-34.png
/home/claude-agent/forge-build/ultratex-cast-20261007/shots/sheet.jpg
/home/claude-agent/forge-build/ultratex-cast-20261007/shots/jog-back.png
/home/claude-agent/forge-build/preseed-v4-20261008/site/media/eng/NOTES.md
/home/claude-agent/forge-build/ultratex-cast-20261007/paint3d/bake2.py
/home/claude-agent/forge-build/ultratex-cast-20261007/paint3d/render.py
/home/claude-agent/forge-build/ultratex-cast-20261007/paint3d/bake.py
/home/claude-agent/forge-build/ultratex-cast-20261007/paint3d/exportglb.py
/home/claude-agent/forge-build/ultratex-cast-20261007/paint3d/stage1.py
/home/claude-agent/forge-build/ultratex-cast-20261007/paint3d/cams.py
/home/claude-agent/forge-build/ultratex-cast-20261007/paint3d/gltfio.py
/home/claude-agent/forge-build/preseed-v4-20261008/site/media/v7/logos/recommerce.png
/home/claude-agent/forge-build/preseed-v4-20261008/site/media/v7/logos/jellysmack.png
/home/claude-agent/forge-build/preseed-v4-20261008/site/media/omg-logo.png
/home/claude-agent/forge-build/preseed-v4-20261008/site/harness_blocks.py
/home/claude-agent/forge-build/preseed-v4-20261008/v4r2-backup/build.py
/home/claude-agent/forge-build/argon-planet-20261001/stats_agy.py
/home/claude-agent/forge-build/ultratex-cast-20261007/out/mask_full_bl.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/mask_full_back.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_head_right.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/mask_full_top.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_check_chest_back.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_full_bottom.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_check_full_left.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_chest_back.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_check_head_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_check_full_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/mask_full_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_full_fl.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_check_chest_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_check_head_back.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_full_bl.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/mask_full_right.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/mask_chest_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_check_full_bottom.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_full_top.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/mask_head_left.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_check_full_top.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_head_back.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_full_left.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_check_head_left.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_head_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/guide_chest_front.png
/home/claude-agent/forge-build/ultratex-cast-20261007/out/mask_chest_back.png

exec
/bin/bash -lc 'cat /home/claude-agent/.agents/skills/blender-game-assets/SKILL.md' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
---
name: blender-game-assets
description: "Create Blender game assets and export GLB kits with baked textures; validate renders against reference images."
metadata:
  provenance: adapted
---

# Blender game assets, headless

Distilled from TheLongSilence's Blender pipeline (162 m ship, carved rocks, a
cabin interior kit, 13 ground materials) into a headless-first recipe for
Forge. **No GUI, no BlenderMCP socket** — Forge's Blender lives on gex44 with
no display, and `blender -b` is the one thing BlenderMCP's addon refuses to
run under. Everything below is a plain `blender -b --python` call or the
gex44 exec service.

For architectural construction kits, read `construction-kit` and its tested
source builders. It supplies cross-family profile/damage/UV contracts and
consumer-side checks; use only the relevant elements.

For a connected playable scene, also read `visual-game-loop` and its
`references/whole-scene-blender.md`: the user-approved harbour recipe combines
scene/object references, photographic materials and coherent Blender construction
with gameplay verification. Reuse good hero assets; author the surrounding place
as a whole rather than assuming a larger modular catalog will fix assembly.

## Read only what the task needs

| When | Read |
| --- | --- |
| A dense model must become a light one that keeps its look (normal + AO transfer) | [high-to-low baking](reference/high-to-low-baking.md) |
| Preparing UVs or a cage, or a bake shows seams, black faces, wrong highlights | [baking and diagnostics](reference/baking-and-diagnostics.md) |
| Choosing texture or mesh formats, or the asset is slow to load or heavy in GPU memory (KTX2, WebP, meshopt, Draco) | [asset delivery](reference/asset-delivery.md) |
| Before any long bake or scripted export pipeline | [preflight and recovery](reference/preflight-and-recovery.md) |
| The asset looks right in Blender and wrong in the game, or the cause could be either | [runtime parity and diagnostics](reference/runtime-parity-and-diagnostics.md) |
| The asset will be judged by eye (hero prop, character, building, environment) | [author and critic loop](reference/critic-loop.md) |
| Terrain, cliffs or rocks should look weathered (branching gullies and ridges, no simulation) | [terrain erosion](reference/terrain-erosion.md) |

These seven are loaded on demand, not by default. Five are ported from vercel-labs/vgpu (see Attribution); terrain erosion uses Rune Skovbo Johansen's filter (MPL 2.0, `scripts/erosion/`).

### Choose how to carry each detail before any bake

| Detail | Carry it as |
| --- | --- |
| Silhouette, openings, broad edges, tuft dimples, piping, moving parts | Real low-poly geometry |
| Fine bevel curvature and shallow relief the triangle budget cannot afford | Tangent-space normals baked from the dense source |
| Permanent cavities and contacts inside a rigid assembly | Baked AO |
| Fabric weave, grain, micro-texture | The existing tiled map at its own scale, never re-baked into a unique atlas |
| Contacts that change, directional shadows | Runtime lighting |

**Pick the cheapest carrier that passes the look check. Being under a texture budget is not a reason to
spend it.** A unique normal atlas is the last resort, for detail the triangle budget cannot hold: a
1024 px RGBA8 map with mips is 5.6 MB of GPU memory per asset, and it replaces a tiled map that every
copy of the material could have shared. If you try a bake and geometry plus the source's tiled maps
looks as good, ship the geometry and say so in the report.

Measured (2 Oct 2026, STRANDMON armchair, 233k to 12k triangles, two Astra runs, same brief): dimples kept
as geometry with the two original 256 px tiled maps = 0.46 MB download, 0.7 MB GPU textures. Same detail
baked into a unique 1152 px normal map = 1.19 MB download, 7.4 MB GPU textures, slightly closer from across
the room, slightly weaker up close, and a blind judge preferred the geometry version.

## Which representation: decide before modelling

For furniture reconstructed from photographs, datasheets or assembly instructions,
read [Furniture reconstruction checks](reference/furniture-reconstruction.md)
before generating geometry. It covers panel-relative coat-hook placement,
interior compartment counts and optional door/drawer articulation.

| Need | Representation | Why |
| --- | --- | --- |
| Hero or enemy character with a walk and attacks | imported rigged mesh (this skill, or the CC0 library: Quaternius, KayKit, Mixamo motions) | skinning, retargetable clips; procedural rigs read as mannequins |
| Weapon, prop, creature variant where silhouette, sockets, collision or code-driven variants matter | procedural three.js geometry or a small GLB kit part | controllable, cheap to vary per instance |
| Concept, icon, backdrop, card art | a generated 2D image used directly | never rebuilt as geometry |
| A reference image whose visible FORM must become a code model | `img2threejs` with its gates; say the result is a reconstruction, not extracted geometry | likeness is judged, not measured |

Pick once at the blueprint or stack-selection stage and record it with provenance (source, licence,
metres per unit, forward axis). Design after MengTo/Skills build-hybrid-game-assets (MIT).

## 1. The recipe

The concept image is the spec, not decoration. For every asset in a kit:

1. **Model every piece in Blender from the concept image** using loft/recess/
   greeble (§3). Grey-clay render it before touching a texture (§4, §10) —
   box-stacking hides under any lighting rig except flat grey + one hard key.
2. **Use a fitting registered photographic texture set or an image model** for
   photographic surfaces (ground, foliage, cloth). Record origin, license and
   physical scale; matching normal/roughness maps must remain aligned. Use an
   existing suitable licensed scan before generating a replacement; use Cycles nodes only for what
   genuinely varies by geometry (edge wear, per-plate tone), and flatten
   before export (§6).
3. **Screenshot-vs-concept loop.** Render next to the concept, same angle if
   possible, compare. Judge by measured numbers where you can (§10: tone
   percentiles, AO floor, draw calls), by eye where you can't (silhouette,
   chamfer read, material believability). Iterate until it reads as the
   concept, not until it "looks 3D".
4. **Export once clay AND texture both hold up.** A model that's flat-correct
   in clay but goes muddy after baking has a material bug, not a modelling
   bug — don't re-model to fix a bad bake.

## 2. Headless setup on gex44

Blender 5.2.0 LTS, x64, RTX 4000 Ada, at
`/root/blender-spike/blender-5.2.0-linux-x64/blender`.

**A. Direct subprocess** (matches the source game's `build_*.py` pattern):
```python
BLENDER = "/root/blender-spike/blender-5.2.0-linux-x64/blender"
cmd = [BLENDER, "--background", "--factory-startup", "--python-exit-code", "1", "--python", __file__,
       "--", "BUILD"] + extra_args
# script: `if 'BUILD' in sys.argv: blender_main(...)` else `drive(...)`
```
Split into `drive()` (host: subprocess + result check) and `blender_main()`
(only imports `bpy`/`bmesh` — importing them outside `-b` crashes with no
display). **Without `--python-exit-code 1`, Blender can exit 0 after a Python error.**
Set that flag, check the exit code and logs, and verify fresh output files.
Mesh/export validation still matters: a successful process is not visual proof.

**B. gex44 exec service** (`/opt/astra-blender/blender_exec_server.py`,
loopback `:8793`, token-guarded): POST a python script, get back stdout plus
render paths, for interactive iteration without a subprocess per call. Same
headless constraint — the script is a `blender_main`-shaped body, no GUI code.

Outputs land in the app's `public/models/` (or the GameKit pack's asset dir)
as `.glb` + `.webp`. Draft render: 64 samples / 1280x720 ≈ 2 s on an M-series
GPU (source game, Apple Silicon) — **gex44/RTX 4000 Ada timing is
unmeasured; get one real number before quoting a render budget.**

## 3. Modelling: loft, recess, greeble

Three techniques, in order of impact.

**Loft cross-sections, never stack primitives.**
```python
def oct_ring(w, h, cx, cz):
    a, b = w * 0.5, h * 0.5
    return [( a, b-cz), ( a-cx,  b), (-(a-cx),  b), (-a,  b-cz),
            (-a, -(b-cz)), (-(a-cx), -b), ( a-cx, -b), ( a, -(b-cz))]

def loft(name, sections, mat):    # sections: (y, w, h, cx, cz, dz)
    bm = bmesh.new()
    rings = [[bm.verts.new((x, y, z+dz)) for (x, z) in oct_ring(w, h, cx, cz)]
             for (y, w, h, cx, cz, dz) in sections]
    for A, B in zip(rings, rings[1:]):
        for i in range(8):
            j = (i+1) % 8
            bm.faces.new((A[i], A[j], B[j], B[i]))
    bm.faces.new(list(reversed(rings[0]))); bm.faces.new(rings[-1])
```
Keep the profile **stepped, not tapered** — a parallel midbody, a shoulder, a
blunt end. Steps are what the eye measures scale against.

**Cut detail into the skin**, not glued on top:
```python
sel = [f for f in bm.faces if pick(f)]
bmesh.ops.inset_region(bm, faces=sel, thickness=1.2, depth=0.0,
                       use_even_offset=True, use_boundary=True)
for f in sel:
    bmesh.ops.translate(bm, verts=list(f.verts), vec=f.normal.normalized()*-0.7)
    f.material_index = i_recess
```

**Detail hierarchy, including restraint.** Primary masses, panel bays, fine
greebles at roughly **1/60 of the asset's longest dimension**. Zone them and
**leave clean plate** — greebling every face reads as noise; detail only
registers against something undetailed. Flat-shade hard surfaces.

**A Bevel modifier does nothing on face-soup geometry** — bmesh ops emit
every face with fresh vertices, no shared edges to bevel. Weld first, then
chamfer:
```python
def weld_bevel(ob, width=0.0025, segments=2, angle=math.radians(31), dist=2e-5):
    me = ob.data
    bm = bmesh.new(); bm.from_mesh(me)
    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=dist)   # joins coincident boxes
    bm.to_mesh(me); bm.free(); me.update()
    apply_bevel(ob, width=width, segments=segments, angle=angle)
```
A 90-degree edge returns no highlight from any light — the single largest
thing separating "modelled" from "a pile of boxes". `o.scale = size` then
Bevel makes pillows (Bevel is local-space) — `transform_apply(scale=True)`
before bevelling.

## 4. Kit-bake pipeline (interior_bake/ distilled)

For a repeated kit (a corridor, a room set, a prop family), stage the build
so nothing bakes twice and occlusion bakes into the placement it ships in:

1. **Tile geo** — hi-poly tile (plates, rivets, hatches) as a **3x3 array**
   so only the centre tile bakes and its edges see real neighbours (no
   cross-fade). Panel tile 2.0 m, deck tile 1.5 m in the source kit.
2. **Tile bake** — bake **height by ray-tracing an Emission shader whose
   colour is world-Z**, not a tangent-space NORMAL bake off a flat cage: a
   vertical panel-gap wall has no cross-section for a downward ray, so a
   NORMAL bake off a plane records nothing for it. Height + a prefilter gives
   both the seams and a slope a texel wide.
   ```python
   sc.render.bake.use_selected_to_active = True   # hi -> flat lo plane
   sc.render.bake.cage_extrusion = 0.05
   ```
3. **Compose** (numpy) — prefiltered normal from height (clamp depth *before*
   differentiating: an undamped 26 mm slot over two texels is a normal lying
   flat, rendering as a crawling black slit); crown/cavity masks drive wear
   and grime; procedural stencils (part numbers, hazard stripes) into albedo.
4. **Kit** — parametric part builders on a shared profile/loft/box library
   (`Part`-style primitives, per-edge bevel, a material "slot" tag).
5. **Props / assemble** — instantiate every master once, `weld_bevel` each
   with a **hand-tuned width per part class** (finer on small props — a
   2.5 mm round-over eats a 6 mm book board), lay the kit out at its in-game
   transforms *before* baking so occlusion matches what ships.
6. **UV + AO bake**:
   ```python
   def unwrap_and_pack(masters, res=2048):
       for ob in masters.values():   # per-object smart UV project, then:
           ...
       bpy.ops.uv.average_islands_scale()
       bpy.ops.uv.pack_islands(margin=0.006, rotate=True)

   def bake_kit_ao(masters, occ, res=2048, dist=1.5, samples=128):
       sc.cycles.samples = samples
       # AO node -> Emission -> Material Output, per material, bake EMIT
   ```
   **AO bakes along the surface normal — wind kit-hull skin facing INTO the
   room**, or it bakes unoccluded everywhere (open space). **Floor-lift the
   result** — occlusion is a modulation, not a shadow; anything reaching
   pure 0 paints a black line lighting can never recover
   (`a2 = 0.34 + 0.66*a**0.85`).
7. **Clay check** before any texture or bake is trusted (§10).

**Texel density**: panel shipped 2048 px / 2.0 m = **1024 px/m**; deck
1024 px / 1.5 m = **683 px/m**; the interior kit's UV/AO atlas ran at
**4096 px** (the bake function's own default is 2048 — override per project,
don't trust a library default). Pick texel density from viewing distance: a
corridor the player's shoulder passes within 40 cm of needs more than a
ceiling never seen close.

## 5. Rocks and foliage

**Rocks** (`build_rocks.py`): carve, don't deform a sphere — a deformed
icosphere is convex-ish every time (the "potatoes" problem). Instead: dense
icosphere → `bmesh.ops.bisect_plane(..., clear_outer=True)` several times for
fracture faces (family-dependent: slab/block/boulder/column/shard) → smaller
bisects near corners for chips → two octaves of `mathutils.noise.noise`
displacement along the normal → `REMESH` (voxel) to heal cuts into one
manifold → `DECIMATE` to a triangle budget → `shade_smooth_by_angle` so
fracture edges stay sharp → bake AO to a `BYTE_COLOR` vertex attribute
(spread instances apart *before* baking — stacked at the origin they occlude
each other into a mean of 0.2) → normalise so the longest axis is 1 and the
low point sits at 0. Ship **8-11 variants across 3 LODs** (full, ~500 tri,
~160 tri) in one GLB, `export_vertex_color='ACTIVE'`, no materials — AO rides
in `COLOR_0`, the shader supplies the surface.

**Foliage** (`pack_leaf.py`): pack an image-model leaf-cluster cutout (RGBA,
transparent bg) into a 512x512 lossless atlas. Normalise each RGB channel's
mean over opaque pixels to 0.5, so the image contributes *variation about the
palette* rather than its own green. Dilate outward into the transparent
margin — bilinear alpha filtering averages the last opaque texel with its
transparent neighbour, and a black neighbour puts a dark fringe on every
edge. Ten dilation passes covers a 512-texel margin at any mip seen.

## 6. Materials

**Procedural (Cycles-only, geometry-driven):** per-plate tonal variation +
pointiness edge wear, the two nodes that make paint look built:
```python
vor = nt.nodes.new("ShaderNodeTexVoronoi"); vor.inputs["Scale"].default_value = 0.16
# RGBToBW -> MapRange 0.9-1.1 -> Multiply into base colour: plate batches
# never quite match, and that mismatch is most of what reads as "welded".
geo = nt.nodes.new("ShaderNodeNewGeometry")
pr = nt.nodes.new("ShaderNodeMapRange")   # From 0.49-0.56, clamp, -> wear factor
nt.links.new(geo.outputs["Pointiness"], pr.inputs["Value"])
# mix toward bare metal + drive Metallic up: free on every convex corner,
# exactly where paint goes in life.
```
Also: bleach on sun-facing normals (`dot(Normal, sun_dir)`), soot along an
object-space axis. **These do NOT survive glTF export** — three.js never
sees the node graph. Bake to a texture before export, or export flat base
colours per material bucket and let the app's own shader apply bleach/soot
from world position (what the source game does).

**Image-model derived** (ground, cloth, rock faces): one generated albedo is
the only photographic input, everything else *derived* in numpy so it can't
disagree with it:
- `height_from_albedo` — 3 band-passed luminance scales (fine/mid/coarse),
  weighted toward the middle — "dark is deep" is the prior every
  albedo-to-normal tool uses and it holds for ground.
- `normal_from_height` — central difference of the height field.
- `ao_from_height` — horizon-based, 16 directions, cone-stepped march.
- `roughness_heuristic` — dust in cavities (rough), exposed faces polish
  (less rough), clamped to an art-direction matte floor.
- **Seam fix in the gradient domain**, not by blending pixel values: take the
  gradient, force it periodic across the wrap by killing the seam's jump
  over a margin of a few px (weight ramps 0→1 away from the seam), then
  FFT-Poisson-integrate back. Mismatch lands in the lowest-frequency mode
  (an invisible ramp) instead of a visible strip at the border — a wide
  margin instead draws bright bars through the derived occlusion.

An image model asked to paint colour onto a rendered height map **redraws
the geometry** (measured: same style, every stone somewhere else). Asked to
*colourise* a flat, shading-free render of the SAME image, it keeps every
stone in place — colourise, don't paint-over, when height must register to
albedo.

## 7. Lighting and camera

**Void/hero lighting**: one hard key (`SUN`, angle ≈ 0.9°), a large weak
fill tinted toward the key's complement, a cold rim so nothing dissolves
into the background. Shadows never fully black. **Camera on the key side** —
a hero angle from the shadow side is carried by fill and reads washed out.

**Clay/QA lighting** (three `SUN`s: key, rim, bounce from below) — the
bounce matters because half of any interior's surfaces face down (an
overhead console, a coaming's underside) and key+rim alone renders them as
unreadable black.

**Camera from the bounding box, never by hand** — hand-placed cameras end up
inside geometry, costing **80x render time** (near-field bounces: 166 s vs
2 s measured):
```python
pts = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box]
lo = Vector((min(p[i] for p in pts) for i in range(3)))
hi = Vector((max(p[i] for p in pts) for i in range(3)))
ctr = (lo + hi) / 2; rad = max((p - ctr).length for p in pts)
cam.location = ctr + direction * (rad / math.tan(cam.data.angle / 2) * 1.05)
cam.rotation_euler = (ctr - cam.location).to_track_quat("-Z", "Y").to_euler()
```
`to_track_quat` assumes Z-up world; if the asset is authored Y-up inside
Blender's Z-up slots, build the look-at basis by hand (cross products off
the target vector and the game's own up axis) or every render is on its side.

**Voronoi stars, never thresholded noise** — noise can't separate density
from size (raise frequency to shrink points → thousands; widen the band to
brighten → falling snow). Voronoi: cell scale = how many, distance threshold
= how big. Cells over a sphere ≈ `4π·scale²`, a frame sees under 1% of it, so
`scale=30` puts ~15 cells in shot — use `scale ≈ 110`. Angular radius =
`threshold / scale`; move both together.

**Backdrop geometry must be camera-visible only.** A large emissive shell
(planet, skybox) hit by diffuse/glossy rays becomes an area light every
shading point importance-samples — measured **145 s → 4.3 s**:
```python
for o in (planet, atmo):
    o.visible_diffuse = o.visible_glossy = o.visible_transmission = False
    o.visible_volume_scatter = o.visible_shadow = False
```

## 8. Export contract for three.js

- **Join per material within each rigid assembly before export.** Keep moving
  doors/drawers, their attachment groups and required named parts separate;
  never merge them into the static carcass just because they share paint.
  One Blender object = one glTF node =
  one draw call. A loose 11-material ship export was 78 draws;
  `bpy.ops.object.join()` per material group brought it to ~11.
- **Metres, Y-up**: `bpy.ops.export_scene.gltf(export_format='GLB',
  use_selection=True, export_apply=True, export_yup=True)`. (The source game
  exports `export_yup=False` because its own runtime is Y-up-in-Blender's-
  Z-up-slots by convention — a plain three.js consumer wants `export_yup=True`.)
- **Flat base colours or baked textures only** — procedural nodes don't
  survive the round trip (§6). `KHR_materials_emissive_strength` DOES
  survive — nav lights, emissive panels keep authored intensity.
- **Draco for real triangle counts**: position 14-bit quantization is ~1 mm
  over a 16 m interior (a tenth of the smallest chamfer), normal 10-bit,
  texcoord 12-bit, compression level 6.
- **WebP out of Blender directly is lossless regardless of the quality
  argument** — its own quality slider is silently ignored. PNG-dump from
  Blender, encode to WebP with PIL on the host if you need real lossy quality.
- **Check draw calls after adding anything** — a duplicated kit piece is one
  draw call against a shared mesh no matter how many times placed; a fresh
  join is not.

## 8b. Destructible vehicles (tank-damage/v1, 11 Oct 2026)

22 library tanks were made destructible by Astra-directed Blender jobs; the contract and pipeline live in forge-assets
`TANK-DAMAGE.md` (job writer `tools/vehicles/make_jobs_tanks2.py`, retrofit `make_jobs_tanks_retrofit.py`).
- TWO GLBs in the same frame: A = healthy, every bolt-on item its own `Part_<Name>` node (complete surface underneath, origin
  at its centre), `Barrel` child of `Gun`, `Muzzle` child of `Barrel` (identity rotation), `Fx_*` empties; B = damage only:
  `Hull_Damage` (`Dmg_` twins at their Part origins, `Scar_<face>` hit marks 2-4 mm proud, `Track_L/R_Broken`),
  `Turret_Damage` at the Turret origin (`Dmg_Turret_*`, `Scar_Turret_*`, `Gun_Broken` at the Gun origin), `Wreck_Hull`
  (material `Burnt`), `Wreck_Turret` with a closed underside. One file would draw wreck + healthy together (no hidden flag in glTF).
- Build the damage from COPIES of the healthy meshes (deform, cut, boolean, decimate) so everything lines up; the job renders
  `states.png` (healthy, hit, stripped, immobilised, wreck, turret off) from the two exported GLBs and looks at it.
- Retrofit an existing model from its previous build script + GLB + baked textures (copy them into a NEW remote dir): the
  offsets stay, so games aim the same way. Check the Muzzle position afterwards (the old Sherman's pointed backwards).
- Six to eight jobs at once worked (healthy file in 10-30 min, damage by ~60, 47-60 min in all; GPU gate on gex44).
  Each writes 50-105 MB into `~/.codex/sessions`: watch the local disk.
- A library card version is immutable: the importer refuses a version that belongs to another job.
- In game the model wants the runtime skin (FGE `module/vehicle-skin.mjs`, the JUGGERNAUT technique) on top of its bake.

## 9. Traps

- Bevel on bmesh-authored geometry does nothing (no shared edges) — weld
  first (§3). `o.scale` then Bevel makes pillows — apply scale first.
- `matrix_parent_inverse = parent.matrix_world.inverted()` cancels the
  parent transform (the "keep transform" idiom). Leave it at identity for
  the child to inherit the parent's rotation.
- A rotation about the wrong axis silently does nothing visible — a panel
  whose normal is +X doesn't change facing when rotated about X. Measure the
  world normal against the view vector rather than reasoning about it.
- Map Range with `To Min > To Max` and clamp on collapses to zero (clamps to
  an inverted interval) — invert with a `SUBTRACT` node instead.
- A Mix node in RGBA mode can silently drop a branch — several sockets share
  the name "Factor". Combine world contributions with two `Background` nodes
  into an `Add Shader` instead.
- The image preview downsamples — 4-px stars average away in a 1080p
  preview. Crop 1:1 and count bright pixels before concluding nothing rendered.
- A tangent-space NORMAL bake off a flat plane records nothing for vertical
  geometry — bake height by ray-traced Emission instead (§4).
- AO bakes along the surface normal — a kit skin wound facing away from the
  room bakes unoccluded everywhere.
- An AO map reaching pure black paints an unrecoverable dark line —
  floor-lift it (§4).
- Blender's WebP writer ignores its own quality setting, always lossless —
  PNG-dump and re-encode on the host (§8).
- Blender exits 0 even when the driven script raised — check stderr and the
  output file, not the return code (§2).
- A socket/exec service keeps Blender's *process* environment between calls
  — a default from a stale env var silently sends output into the wrong
  checkout. Assign output paths explicitly every call, never `setdefault`.

**Blender 5.x API changes** (4.x code fails on all of these):
- `Scene.node_tree` gone → `Scene.compositing_node_group`;
  `CompositorNodeComposite` no longer exists — the group's output socket IS
  the result.
- **The compositing group must contain a `CompositorNodeRLayers`.** Feeding
  it only from a Group Input means nothing depends on the render, so Blender
  skips rendering and writes a transparent black frame in 0.1 s. A
  suspiciously fast render is this bug.
- `Action.fcurves` is gone (slotted actions). Set
  `context.preferences.edit.keyframe_new_interpolation_type` *before*
  inserting keys.
- Glare/Lensdist settings moved from properties onto **sockets**; enums take
  display names (`"Fog Glow"`, `"Streaks"`, `"High"`), not old identifiers.
- Legacy addons still install to `~/Library/.../Blender/<ver>/scripts/addons/`
  on macOS — irrelevant on gex44, relevant for local proving.

## 10. Gates (measurable, not "looks right")

- **Independent export audit**: run
  `python3 scripts/audit_glb.py /absolute/path/asset.glb --strict` from this skill
  directory (requires numpy). It reports mesh-local triangles, nonfinite values,
  degenerate triangles, material groups and texture portability. Exit 1 means
  findings; exit 2 means invalid/unsupported input, never a clean bill of health.
  Audit uncompressed output before Draco/meshopt and inspect the compressed
  consumer separately. It does not validate winding, source solids, intersections,
  node-transformed placement, runtime draw calls or gameplay. Keep source meshes
  for topology/volume inspection; check transformed instance bounds in-game.
  The harbour correction's own empty issues list missed 16 zero-area triangles.

- **Rigged-asset rejection gates** (block the asset before it reaches a build; each one is a
  known failure class):
  - a character MAIN model still contains swappable gear (gear is separate parts on sockets);
  - action/animation files carry duplicate render meshes;
  - skeletons or bone names differ across actions of the same character;
  - a required role, socket (`target`, `ground`, `vfx-hit`, `vfx-death`, `attack-*`), collider,
    equipment-slot declaration or provenance field is absent;
  - toggling gear leaves visible fragments or holes without a declared underlayer boundary;
  - an attack animation has no authoritative timing and collision contract (see
    `_game-kit/core/ENEMY-CONTRACT.md`);
  - status language is truthful: `catalog-only` (recorded), `review-only` (inspector exists),
    `runtime` (gameplay loads it). Only `runtime` counts as shipped.
  Mechanical half: `audit_glb.py --rig --require-sockets target,ground,vfx-hit,vfx-death` reports
  skins, joint names, duplicate primitives (identical geometry buffers) and missing named nodes.
  Design after MengTo/Skills build-rigged-game-assets requirements (MIT); the human half stays a
  checklist.
- **Tone stats**: percentile clipping, e.g. "0.00% of pixels clip and the
  99th percentile is 165" — actionable; "looks flat" is not.
- **Contact sheets**: render every set-piece/angle into one grid image,
  judge the set at once rather than one screenshot at a time.
- **Clay check before any bake is trusted**: flat grey, hard key + rim +
  bounce, no textures, no emissives. A silhouette reading as a rectangle or
  an edge reading as infinitely sharp means the model isn't finished —
  textures and lighting hide box-stacking completely, flat clay does not.
- **Re-verify the capture isn't stale**: if the render/dev surface can
  hot-reload mid-capture, re-check the expected state is actually showing
  before saving, restart if not — a loading-state screenshot with a
  plausible number next to it is a silent false pass.
- **Draw-call and fps survey** after adding any asset: report draw count
  against the pre-change baseline (join-per-material should hold it flat)
  and frame time on the heaviest scene the asset appears in.
- **AO sanity**: print the raw bake's percentile spread (1st/10th/50th/90th)
  before floor-lifting — `min == 0` over a large area means winding or
  distance is wrong, not that floor-lift will fix it.
- Numbers not yet measured on gex44/RTX 4000 Ada (draft/final render time,
  bake time for a 4096 atlas): **unmeasured** — get one real run's numbers
  before writing them into a plan or a budget.

## Attribution

Distilled from TheLongSilence (MIT License, Copyright (c) 2026 Anshu
Chimala). Runnable reference code copied under the same license into
`reference/` — see `reference/INDEX.md`.

`reference/high-to-low-baking.md`, `baking-and-diagnostics.md`, `asset-delivery.md`,
`preflight-and-recovery.md` and `runtime-parity-and-diagnostics.md` are ported from
vercel-labs/vgpu `skills/vgpu/blender/references/` at commit 68cb1d8d198c (MIT License,
Copyright (c) 2025 Vercel, Inc.), adapted from vgpu/WebGPU to the three.js runtime.
`reference/critic-loop.md` is distilled from the same repository's asset agent flow.

hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cat /home/claude-agent/AGENTS.md' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
<!-- GENERATED from CLAUDE.md by forge-hub/hub/gen_agents_md.py — do not edit by hand. -->
<!-- Re-run: python3 /home/claude-agent/forge-hub/hub/gen_agents_md.py -->

# Forge: AI-Powered App Factory
Single-tenant server (16GB ARM64 Hetzner), runs alongside Detecteev (forensics platform).

## Navigation
- **Forge app**: `/home/forge/app/` (Python, DBOS workflows)
- **Forge UI**: `/home/forge/ui/` (React + FastAPI, port 8765)
- **Detecteev**: `/home/claude-agent/detecteev/` (Next.js + FastAPI)
- **Project sandboxes**: `/home/forge/data/projects/{slug}/sandbox/`
- **Skills**: `/home/forge/app/skills/` (reasoning + stack-specific)
- **Deployed apps**: `/home/forge/deploys/{slug}/`
- **Specs & plans**: `/home/claude-agent/forge-build/`

## Architecture at a Glance
- **Pipeline**: intake → ideation → research → challenge → business → prd → feature-spec → stack-selection → blueprint → scaffolding → feature-build → dx-audit → product-review
- **Native Claude CLI calls use `--model sonnet`**. Provider adapters explicitly select their own API model; DeepSeek uses `deepseek-flash[1m]` through Claude Code.
- **Autonomy**: all stages auto by default, no human gates
- **Deploy**: Docker containers at `{slug}.forge.le-borgne.fr` via Traefik
- **Feedback loop**: report issue → AI drafts fix → user approves → auto-redeploy

> ⚠️ The pipeline above is the **legacy Personal Forge** (forge.le-borgne.fr). The live product is **Forge Corp** — multi-box, multi-tenant. Read the next section for current topology.

## Forge Corp topology & deploy architecture (CURRENT — read this)

**Boxes & roles** (each box has its OWN `/home/claude-agent`, memory store, and CLAUDE.md — they do NOT share):
- **local** `204.168.129.84` — orchestrator. Opus planning, this memory store, the `coord` bus. No app/infra runs here.
- **ax42** `5.9.32.176` (`ssh root@5.9.32.176`) — Corp **Engine** (the build+deploy pipeline `deploy_sandbox`) + Postgres **primaries**. Canonical Engine source: `/home/claude-agent/forge-monorepo/apps/forge-engine/` (module `forge_engine`).
- **box1** `136.243.170.223` (`ssh box1`) — Corp **app-runner**: deployed tenant apps (zero/friends/recommerce) run here + PG **standbys**.
- **gex44** `5.9.10.114` (`ssh root@5.9.10.114`) — **Factory** (auth/Google-OAuth proxy + tenant UI) + inference (Flux/3d/LiteLLM) + **Caddy edge**. Canonical Factory source: `/home/claude-agent/forge-monorepo/apps/forge-corp-factory/`.
- **prod** `46.224.168.31` (`ssh prod`) — **Tower** (operator control plane) + dashboard; also legacy Personal Forge + Detecteev.

**Canonical source = the live HOST trees, NOT git.** The Engine is image-baked (no bind-mount): deploy = `docker build` from the ax42 host source; Factory builds from the gex44 host source. The GitHub repo `T1misageek/forge-monorepo` is a periodic **snapshot/backup**, NOT the build source. NEVER treat git as source of truth. Before any Engine/Factory rebuild, reconcile **host ← live container** (a single-file rsync from the mirror silently drops live state). httpx is in the Engine container, not host Python.

**Engine runs blue/green since 2026-09-02 13:11 UTC.** nginx `forge-engine-front` owns host `:8000` + docker alias `forge-engine`; Engine instances are `forge-engine-<ts>` (no host port). There is NO container named `forge-engine` any more: use `docker exec $(engine-live) …`, `docker logs $(engine-live)`. Deploy = `engine-deploy` (= `deploy.ax42.sh`: build → start new instance → `engine-switch` nginx reload → drain old, **no idle gate, nothing killed**); `engine-rollback` flips back (refuses a stopped previous); `engine-restart` no-arg = `engine-deploy --no-build`. **Builds AND refine turns survive an instance death** (P1 09-02 + P2 09-03): a 15 s sweep adopts orphaned runs (`pipeline/adopt.py`) and refine turns (`refine/adopt.py`: `metadata.active_turn`, `claude --resume` of the shared transcript store `~/.claude-engine`), proven by hard-kill on pausetest tenants. Never kill an instance without reading `/admin/inflight` first. **P3 (09-03)**: brief submits are idempotent (`X-Forge-Idempotency-Key` = Factory brief id → `runs.idempotency_key`, replay = 200 + `x-forge-idempotent-replay: 1`); the Factory retries only when Engine `/health` advertises `capabilities.idempotent_submit`; the reconciler re-polls `failed` runs < 6 h. `/admin/instance`, `/admin/inflight`, `POST /admin/drain` (consumer Bearer key). Detail: memory `project_engine_zero_downtime_deploys.md`.

**Deploy pipeline `deploy_sandbox`** (`forge_engine/deploy/__init__.py`, synchronous): Step 0 Dockerfile/.env → 0b telemetry route → 0c connectors → 0d cache → 1 build → 1b transport to box1 → 2 run (reaps prev container immediately, so **no `-prev` rollback exists** by later steps) → 3 inspect IP → 4 DNS → 5 traefik → **6 authed-smoke gate**.

**Step 6 — deploy-time authed-smoke gate (`forge_engine/deploy/smoke.py`)**: after cutover, mints a REAL synthetic session and GETs the app's post-login routes (pages referencing `requireUser`/`getCurrentUser`). Catches the "builds + homepage-200 but 500s after Google login" class (e.g. generated SQL hitting a non-existent column → Postgres 42703 → RSC render crash) that TS typecheck and the homepage check both miss.
- ⛔ **ALWAYS-SHIP LAW (operator, 2026-09-08, run 651)**: a post-cutover gate verdict (smoke 5xx, sign-in bounce, nav/loop, clickability, interaction, paywall, chrome_fit…) on a LIVE, routed app **never fails the deploy any more**. `forge_engine/deploy/ship_policy.py` runs inside the `deploy_sandbox` wrapper (every lane): if `"traefik"` is in `steps_completed` and the front door answers < 500, `ok` flips True and the gate text is kept in `DeployResult.shipped_with_findings`, the corp-deploy `gate_report`, and `runs.meta.shipped_with_findings` (ERROR log `SHIPPED WITH FINDINGS`). Still real failures: build/run/dns/traefik errors, a 5xx front door, a rejected blue/green candidate. Kill switch `FORGE_ALWAYS_SHIP=0`. Memory `feedback_always_ship_live_builds.md`.
- Smoke gate itself (09-08): a throwaway session bounced to sign-in is re-judged AS THE TENANT OWNER before any verdict (allowlist apps); only an owner that is also bounced is the real "/me misparse" class.
- Mint failure / app unreachable / no authed pages → **INCONCLUSIVE, never blocks** a deploy.
- **Mint path**: Engine can't reach the Factory DB, so it POSTs `https://forge.{tenant}.ohmyunicorn.com/api/auth/oauth/google/_smoke` (in `forge_corp/api/routes/oauth_google.py`) guarded by header `x-forge-smoke-secret` (wrong/unset → 404). Shared secret in `/home/claude-agent/.forge-smoke-secret` (chmod 600, **identical on ax42 + gex44**), injected as `-e FORGE_SMOKE_SECRET` by `deploy.ax42.sh` (Engine) and `deploy.sh` (Factory). Absent secret ⇒ /_smoke stays 404 ⇒ smoke INCONCLUSIVE (never blocks).
- **Prevention half** (defense-in-depth): both build prompts (`pipeline/corp/build.py` + `pipeline/fast/fast_build.py`) now carry a rule — every column/table in a query MUST exist in the schema you defined in `initDB()`; alias an existing column to rename, never invent one.
- Detail in memory: `reference_authed_smoke_gate.md`, `reference_authed_page_sql_column_gap.md`.

## Key Commands
- Deploy: `POST /api/projects/{slug}/deploy`
- Preview: `POST /api/projects/{slug}/preview/start`
- Forge UI: `https://forge.le-borgne.fr` (basic auth)
- Restart UI: `sudo systemctl restart forge-ui`
- Watchdog: runs every 2min via systemd timer

## Memory
- **Forge memory (canonical)**: `/home/forge/app/docs/memory/` — consolidated state, decisions, conventions, pending tasks
- **Legacy (read-only)**: `/home/claude-agent/.claude/projects/-home/memory/` — kept for reference, no longer updated

## Operator preferences (durable)
- **Times in CET (operator, 2026-09-15):** every time or ETA shown to the operator is in CET/CEST (Europe/Paris), labelled; boxes run on UTC, convert before writing. Applies to worker briefs and published docs too.
- **Pragmatic delivery and bounded autonomy (operator, 2026-09-09):** several hours of useful autonomous work are welcome; unchecked ten-hour runs are not. Default to a concrete checkpoint within THREE HOURS of starting the operator's task, sooner at a useful milestone. Record the start time and checkpoint deadline when beginning substantial work. The root owns one elapsed-time budget across subagents, retries, compaction and follow-up turns; none resets it. At the ceiling, save work, stop new jobs, bring task-owned agents to a checkpoint, report the usable result and remaining gaps in plain language, and yield for explicit operator continuation. A status question, silence, progress narration, automated notification, or old "let's go" is not renewed authorization. The operator may explicitly set a different window.
- **Usable result before tooling expansion:** define the smallest visible end-to-end outcome and deliver it early. Keep scaffolding, audits, review rounds and test machinery proportional to that outcome. Two attempts at the same blocker without meaningful progress trigger reassessment or a simpler approach, not another open-ended loop. Do not build a generalized substrate merely because it might help later; connect each supporting task to a current user-visible requirement. Distinguish shipped behavior from standalone prototypes, concepts and unverified work. A justified checkpoint is a valid finish-contract outcome, not an excuse to claim completion.
- **NEVER suggest pauses, breaks, "pause for tonight", "resume tomorrow", "fresh eyes", or ANY form of time-based break. EVER.** This is the single most annoying behaviour for the operator. *Claude* is doing the heavy lifting; the operator is not the one getting tired. Time of day is irrelevant — push through, period. Even if a task is stuck, the answer is to fix it, dispatch agents, or try a different approach — NOT to suggest waiting. If a clean reinstall is the answer, just do it. The operator decides when to stop, not Claude.
- **Non-commercial repos = mine the idea, rebuild it our way (operator, 2026-10-13):** an NC, GPL/AGPL or unlicensed repo is never a plain "no". Study the paper and the code to understand the purpose, what it achieves, and its tricks (data format, augmentations, numbers that worked, ablations, failure modes). Then rebuild the technique with what we own (Gemini, Omni, three.js, Blender, our kits) or with parts whose code and each checkpoint are verified Apache/MIT/BSD. Prove it small and measure it. Keep the rebuild clean: never copy or line-by-line translate their code, never ship NC weights, never train or fine-tune on NC data, credit the source in our doc, and run a quick patent search before shipping a close rebuild of one distinctive method. Memory `feedback_nc_repos_mine_idea_rebuild_our_way.md`.
- **Quality over cost** within the agreed scope and checkpoint window. Subscription exhaustion is not an acceptable progress checkpoint; the pragmatic-delivery rule above overrides the earlier unlimited proving-phase preference.
- **Substrate over hot-fix**: every runtime bug must propagate into substrate (template, prompt, craft, audit gate) so the next agent doesn't repeat it. Hot-fix-only is a half fix.
- **Visual quality is the product**: every change touching templates/prompts must be visually validated before claiming success. "It builds and deploys" ≠ "it looks right".
- **Delegation:** work locally by default. Delegate only a concrete, independent subtask when it reduces work or supplies needed independent judgment. Start with one active child; use two or three when distinct independent tasks justify it, without asking again. Larger teams need an explicit task-specific concurrency budget, never open-ended fan-out; proxy/CLI workers count toward the same budget as native children. No recursive delegation by default. All workers share the root checkpoint deadline. Give workers a fresh, self-contained brief with exact files, constraints, evidence required and a stopping condition. Full-history forks are exceptional, never a default.
- **Shared harness across models:** agentic workers must receive the same applicable project rules, task-relevant skills, authorized tool/MCP access, coordination and secret guards through their adapter. Load skills selectively, not all at once. Preserve isolation and task-specific permissions; equal harness support does not grant broader authority. Missing wiring is an adapter gap to report or fix, not a reason to declare a model incapable. Verify the chosen lane rather than assuming every helper inherits the full setup.
- **Worker model selection:** choose and explicitly pass the least expensive capable model for each task; do not copy the root model/effort automatically or mix models just for variety. **Every Gemini call is Gemini 4 Argon (operator, 15 Oct 2026; never 3.8 Flash or 2.5):** agentic work through the Antigravity CLI (`agy4`), single-shot calls through the gateway with a thinking level (Gemini 4 refuses budgets; its thinking counts against max_tokens). Gemini 4 (`forge:build-gemini-4`, `-low`, `-high`) is eligible for bounded coding, investigation and review. Local builders use `forge-agent run --harness gemini --model forge:build-gemini-4 --repo <repo> --purpose <task> --task <brief>` with a bounded `--timeout`; since 14 Oct 2026 this runs Google's Antigravity CLI (`agy4`, `forge-hub/agent/AGY4.md`) with the shared rules, skills, Forge MCP and our guards bridged as agy hooks, every call to Vertex project forge-dev-492809 (Google grant); `FORGE_GEMINI_HARNESS=claude-code` or `--jail` = the old Claude Code lane. Proven locally with actual Gemini responses, MCP skill/coord calls, file edits, tests and a blocked secret-guard probe. `--harness gemini-proxy` remains the single-shot no-tools helper for tasks that need no tools. The local Gemini builder currently rejects optional `--jail`; never imply that it ran in a container sandbox. Use `gpt-5.6-luna` for narrow mechanical work, `gpt-5.6-terra` for exploration and bounded implementation, `gpt-6.1-sol` (Sol) for demanding implementation/debugging/review, and `gpt-6-astra` for unusually difficult architecture or reasoning. **Sol = `gpt-6.1-sol` (operator, 2026-09-30):** every Sol request runs GPT-6.1 Sol (Codex CLI >= 0.159.2; 0.156 gets a 400). `sol`/`solx` call it, and the Engine swaps `gpt-6-sol` and `gpt-5.6-sol` for it at launch (`cli/sonnet_retire.py` LATEST_VERSION, also inside `run_tracked_codex_cli`). `gpt-6-luna` (Codex CLI >= 0.156.0) is the GPT-6 option for the Luna tier; not benchmarked yet, so `gpt-5.6-luna` stays the Luna default until a measured run shows it is worth its cost. Claude CLI workers use `--model sonnet`, which is **Sonnet 5.5 only** on this box (alias pinned in `~/.claude/settings.json` since 2026-09-28; never Sonnet 5; the Engine still remaps Sonnet to Opus 5.5). **Haiku 5.5 (2026-10-08):** `--model haiku` = `claude-haiku-5-5` everywhere (alias pinned in `~/.claude/settings.json`; the Engine seam upgrades `haiku`/`claude-haiku-4-5` at launch): $0.10 in / $0.50 out per MTok, 1M context, default effort medium. Use it for classification, extraction, routing, yes/no gates, judges and cheap subagents at effort low or medium; `haikux "prompt"` one-shot, `review --by haiku` for the cheapest second look (same family, not independent), panel seat `haiku-local`. Always pass `--effort`: low for mechanical edits, medium by default, high only for creative or hard work (and for benchmarks, the same effort as the run it is compared with). Native Codex defaults are Terra/medium; explicitly select low effort for straightforward tasks and high only where the reasoning warrants it. Prefer a different family for independent judgment when useful. Escalate after evidence of a capability limit instead of repeated cheap retries; name unavailable models and use a supported alternative. Codex mixed-model spawns use `fork_turns="none"` with a self-contained brief; full-history forks inherit the parent's model and effort.
- **Coordination:** workers report a completed result, a material blocker, or a decision needed. Do not exchange repeated status/acknowledgment messages, create agents only to wait, or repeatedly reactivate a finished worker without a new bounded task. Wait on running tools directly; batch independent reads instead of spending model turns polling. The root keeps useful local work and integrates the result.
- **Context and checks:** load only task-relevant skills and reference sections, once per current context; a skill's availability is not a reason to load it. Use scoped searches and bounded output; keep full logs in artifacts. Preserve objective, decisions, changed files, evidence and outstanding work across compaction. Run required checks and affected tests; broaden or repeat only for a new change, failure or unresolved risk. Stop when the requested outcome is evidenced.

## Talking to other models in the CLI (2026-09-17)

Three model CLIs, same shape, all with full tool access and no approval prompts
(safety = the PreToolUse secret + coordination guards, not a sandbox):

| command | model | one-shot | extra flags |
| --- | --- | --- | --- |
| `astra` | Codex GPT-6 Astra | `astrax` | Codex flags |
| `sol` | Codex GPT-6.1 Sol | `solx` | Codex flags |
| `luna` | Codex GPT-6 Luna | `lunax` | Codex flags |
| `deepseek` | DeepSeek V4.1 Flash | `deepseekx` | `--effort low|high|max` |
| `gemini` | Gemini 4 Argon in Google's Antigravity CLI (default since 14 Oct 2026; 3.8 Flash requests are upgraded; `gemini-cc` = old Claude Code lane) | `geminix` | `--model forge:build-gemini-4{,-low,-high}` |
| `agy4` | the launcher behind `gemini`/`gemini4`: Antigravity CLI + relay -> alias `forge-agy-gemini-4` (Google grant) | `agy4 -p` | `--model`, `--effort low|medium|high`, `--worker`; doc `forge-hub/agent/AGY4.md` |
| (none) | Claude Haiku 5.5 (`claude-haiku-5-5`, via `claude-lean`) | `haikux` | Claude Code flags (`--effort low`, `--tools ""`) |

All four are executables in `~/bin`, not shell functions: `~/.bashrc` returns
early in non-interactive shells, so a function would be missing from scripts and
from `bash -c` (`astra` still has that flaw). `--ask-permissions` restores the
approval prompts. `deepseek` and `gemini` are Claude Code driving another
provider's endpoint, so every Claude Code flag passes through (`--continue`,
`--resume <id>`, `-p`).
Each has a persistent private profile (`~/.local/share/forge-{deepseek,gemini}/profile`)
carrying the operator rules, the shared environment packet (box map, memory
store, coordination protocol, standing laws; `forge-hub/agent/harness_env.py`),
a copy of `~/.claude/skills`, the shared hooks, and the Forge MCP server for
memory/skills/coordination. Docs: `forge-hub/agent/DEEPSEEK.md`, `GEMINI.md`.

Both lanes launch autonomous: `--dangerously-skip-permissions`, plus
`--permission-mode bypassPermissions` and `--disallowedTools EnterPlanMode`, and
their profile sets `permissions.defaultMode: bypassPermissions`. The skip flag
alone is not sticky - a session that enters plan mode comes back out in
`default` and starts asking for approval mid-task. `--ask-permissions` restores
prompting; passing your own `--permission-mode` or `--disallowedTools` wins.

Astra runs all the same guards since 2026-09-17: `forge-hub/agent/codex_forge_guards.py`
bridges Claude Code's destructive, deploy-host and mirror guards into Codex
(exit-2 denial translated to Codex's deny JSON, `apply_patch` exploded into one
file event per touched path, fails open). The guard scripts are not forked; the
Claude copies in `~/.claude/hooks/` stay the single implementation.
**No waiting parent (2026-09-20)**: `forge-hub/agent/codex_wait_guard.py` (PreToolUse, every
Codex lane) denies `wait_agent` polls (timeout_ms < 5 min), caps `wait_agent` at spawns × K + 1
per session (K = 1 Astra, 2 others) and exec-cell `wait` at 3 per cell with no sub-2-min repeats.
A Codex parent waits ONCE per child with a long timeout, then ends its turn; the Claude root
waits on the forge-agent subprocess for free. Kill switch `FORGE_WAIT_GUARD=0`, budget
`FORGE_WAIT_GUARD_K`. Denials log to `/tmp/codex_wait_guard.log`. Claude-lane twin
`~/.claude/hooks/wait_guard.py` (settings.json PreToolUse `Bash|TaskOutput|Read`, copied into the
DeepSeek and Gemini profiles at every launch): a bare `sleep N; <check>` turn outside a loop is
allowed twice per session then denied; `TaskOutput` / reads of a `tasks/<id>.output` file twice per
task then denied. Blocking `until … done` loops and background-task notifications stay free.

**Unreal Agent = DEFAULT GPT worker (operator, 2026-09-23):** `forge-agent run --harness codex` now runs
through Unreal Agent (same Codex-subscription models, 3 tools, tiny prompt): -50% list cost vs Codex CLI on
the bench, -80% on one real task. Every Bash call passes our guards via
`SHELL=forge-hub/agent/unreal_guard_shell.py`. No native MCP: since 2026-10-11 its workers reach the Forge
MCP server from Bash with `~/bin/forge-mcp` (= `forge-hub/agent/forge_mcp.sh`, Apify mcpc 0.7.0 in
`~/.local/lib/mcpc`; `forge-mcp --json tools-call memory_search query:="..."`), a private session per worker
in `.harness/mcpc` (closed when the run ends) that makes coord calls as the worker. No resume yet. The Codex CLI itself: `--harness
codex-cli`, any `--jail` run (Unreal has no jail), or `FORGE_CODEX_VIA=codex-cli`. Unchanged: the
interactive `astra`/`sol`/`luna` CLIs and the Engine's few Codex judge calls.
Memory `reference_unreal_agent_harness_assessment_2026_09_22.md`.

**Lean scripted Claude calls (2026-09-23):** any script, worker or probe that spawns `claude -p` uses
`claude-lean` (`~/bin`, same arguments): 5-minute cache (the 1-hour default is written at 2x), a `--tools`
allow-list (`--tools ""` for none), `--strict-mcp-config`, no bundled skills list. Engine code goes through
`run_tracked_claude_cli` (`cli/lean_launch.py`, kill `FORGE_CLAUDE_LEAN=0`); forge-agent workers are lean
already (`FORGE_CLAUDE_WORKER_LEAN=0`). Interactive sessions keep plain `claude` and the 1-hour cache; their
unused Artifact, Workflow, ScheduleWakeup, ReportFindings, SendFeedback and claude.ai connectors are off in
`~/.claude/settings.json`. Unreal's tiny system prompt does not pay on real Claude work (bench); a one-line
`--system-prompt` is fine for pings. Memory `reference_claude_code_lean_mode_bench_2026_09_23.md`.

**Advisor tool (2026-10-08):** `claude --advisor opus` (or setting `advisorModel`, or `/advisor opus|off`) lets a cheaper
main model consult a stronger one at decision points (plan, repeated failure, before "done"). Proven on this box: CLI
2.1.284, subscription, `-p --model sonnet --advisor opus` = Sonnet 5.5 + Opus 5.5. The advisor must rank at or above the
main model (an Opus root can only take Opus/Fable); Haiku 5.5 on either side needs CLI >= 2.1.293. The model picks when to
call it (ask in the prompt; no cap); every call re-reads the full transcript uncached, so cost grows with context. Anthropic
API only: not through the DeepSeek/Gemini gateway lanes; off when feature-flag fetching is off (`DISABLE_TELEMETRY`); kill
`CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1`. Not wired into the Engine or forge-agent yet (bench awaits operator). There is no
`--subagents` flag. Memory `reference_claude_code_advisor_tool_2026_10_08.md`.

**Max plan API credits (2026-10-08):** each Max 20x plan gives $200/month (Max 5x $100) of Claude Platform credit, claimed on
claude.ai (Settings > Billing > API credits > Link organization) into ONE Console org, permanent choice. Covers the API,
Batches, Managed Agents, Agent SDK and `claude -p` run with that org's API key; NOT Claude Code signed in with the plan,
not extra usage. No rollover, spent before purchased credits, requests stop when out unless the org has bought credits.
Not claimed yet. Memory `reference_max_plan_monthly_api_credits_2026_10_08.md`.

Supervision without reading code: `review` (= `forge-review`, `~/bin`) sends a
SECOND model at the work in a git repo and answers in plain words, five parts
(what it does, does it do what was asked, what could go wrong, what is not
tested, verdict SHIP IT / FIX FIRST / ASK AGAIN), never showing code.
`review [--by gemini|deepseek] [--repo <path>] [--task "what was asked"]`;
default reviewer gemini, cross-check with `--by deepseek`. Reviews uncommitted
work, else the last commit, else the root commit. Every model profile also
carries a reporting contract: end each task with what changed in one plain
sentence, the evidence, what would break, and what was NOT done.

Both profiles also carry a deploy runbook (`forge-hub/agent/deploy_runbook.md`,
injected by `harness_env.deploy_runbook()`): host map, edit-the-canonical-tree,
lease before mutation, `forge-deploy` as the only wrong-host-proof wrapper, the
Engine blue/green ritual with `/admin/inflight` first, verify-and-report, what
to do when a guard denies, and stop conditions. Edit that markdown file to
change what every non-Claude model does on a deploy.

## DeepSeek local harness (2026-09-11)

- Interactive CLI: `deepseek` (= `forge-deepseek`, DeepSeek V4.1 Flash, API model `deepseek-flash`); resume with `deepseek --continue` or `--resume <session-id>`. `--effort low|high|max` selects reasoning effort. One-shot: `deepseekx "prompt"`.
- Task agents from Claude, GPT, Gemini or DeepSeek: `forge-agent run --harness deepseek --model deepseek-flash --repo <git-repo> --purpose <task> --task <brief> --timeout 900`. Same isolated worktrees, shared Forge MCP memory/skills/coord, hooks and finish rules. DeepSeek orchestrators can call the other `forge-agent` harnesses; workers do not recursively delegate.
- Thin adapter: `forge-hub/agent/deepseek_worker.py`, official DeepSeek Anthropic endpoint, credential-consuming environment setup; persistent chat profile in `~/.local/share/forge-deepseek/profile`. No new agent loop. Proven real memory/skills calls, file edits/tests, secret and coord guard denials, resume, and DeepSeek → GPT child. No Docker jail support on this local lane.
- Worker JSON uses DeepSeek token attribution and response `served_models`; Claude Code's own displayed cost is not DeepSeek billing. Use provider billing. Details: `forge-hub/agent/DEEPSEEK.md`.

## MÖBELKRIG game repo: one reference main (operator, 2026-09-27)

Repo `~/forge-build/ikea-fps-20260925/game` (the flat-pack store FPS). `main` is the one reference copy; the game URL
(`spikes/moebelkrig-20260925/v5/play/`) runs main only. Everything goes through `moeb` (`~/bin/moeb`, rules in the
repo's CONTRIBUTING.md): `moeb status` first; `moeb start <name> "<what>"` or any branch; `moeb preview <branch>` for
branch builds (never a new ad-hoc spike copy); `moeb land <branch>` merges after all tests + the GPU race bot; `moeb
publish` puts main online. A git hook refuses any other move of main; `publish-preview.sh` refuses the game URL
outside `moeb publish`. Player-visible changes: preview, operator OK, then land. Versions page:
`spikes/moebelkrig-20260925/versions/`.

## Personal substrate skills

Loaded from `/home/claude-agent/.claude/skills/`. Select by the task-specific trigger; do not load this entire list. The short entrypoint routes to details only when needed:

- **ai-film-craft** — applies when building/reviewing video-generation pipelines, prompts, storyboard lanes, or anything that spends on video models
- **forge-design-language** — applies when building any UI, generating CSS, or reviewing frontend
- **cl4r1t4s-reasoning** — diagnose AI harness behavior or audit consequential assumptions
- **impeccable-craft** — polish frontend design or user-facing copy
- **forge-thought-patterns** — plan complex Forge work or diagnose recurring cross-component failures
- **omni-beat-vfx** — applies to any VFX beat on real talking-head footage (Omni gens + the deterministic lane, gates, clean-edge paste, colour + sound laws)
- **gfx-beat-deterministic** — applies to drawn/graphic beats (badges, stickers, panels, logos, rocket scenes, orbs) and to the Director's `gfx` graphics-beat lane
- **sim-town-games** — applies when building or fixing a tycoon, business-sim or town-sim game on the Forge Game Engine (new game = pack + town seed; laws, crowd, shopfronts, release copy)
- **code-video** - applies when a video shot needs exact motion, timing, physics or a precise camera move, or a brand logo on a product (three.js preview, smooth camera rig, Omni 1.1 edit; Veo first+last frames cannot do choreography)

Each skill is a self-contained SKILL.md with frontmatter declaring its triggers.

## Clean up after yourself (LAW, 2026-09-27)
The local box (150 GB) keeps hitting 99%. A full disk kills every session's tool output. Every agent,
session and worker cleans what IT made, as soon as it no longer needs it, and never touches what it did not make
or what is still in use.
- **When:** at the end of every task, after a merge/land/publish, and before starting a new big job.
- **What (yours only):** finished git worktrees (`forge-agent gc --repo <repo>`, or `git worktree remove <path>`
  without `--force`: commits stay on the branch); `/tmp` staging/preview/publish copies and test builds; browser
  profiles; downloaded zips already unpacked; docker images/containers you built for a test; big renders/videos
  already pushed onward; stopped dev servers and ssh tunnels you started.
- **Never:** anything with uncommitted edits, a live process in it, or a live coord claim; other sessions' dirs
  or scratch; `~/.claude` transcripts, `~/.codex/sessions`, `~/.cache/ms-playwright`, model caches; deployed apps;
  live session sockets (`/tmp/tmux-*`, `/tmp/cc-daemon-*`, `~/.hq/`): deleting one stranded 21 tmux sessions until
  10 Oct 2026 (now in `~/.hq/legacy/`, attach with `tmuxl`).
  If unsure whose it is, leave it and `coord note` it.
- **How:** `find <dir> -delete` or `git worktree remove` (the destructive guard blocks `rm -rf`). Check
  `df -h /` when you finish; under 15 GB free, say so in your report.
- **Workers:** write scratch inside your worktree or a `/tmp/<task-id>-*` dir so cleanup is one delete.
- Memory `feedback_clean_up_after_yourself.md`.

## Secrets never enter a transcript (LAW, 2026-09-07)
A PreToolUse hook (`~/.claude/hooks/secret_guard.py`, matcher Bash|Read|Grep, tests in `hooks/tests/test_secret_guard.py`) DENIES reading credential files (`.credentials`, `~/.forge-secrets/`, `.forge-smoke-secret`, `auth.json`, `.env*`, `*.key`, `*.pem`, ssh private keys, `.netrc`, `.pgpass`, `*secrets*.json`), environment dumps (`env`, `printenv`, `docker inspect` without a narrow `--format`, `docker exec … env`, `/proc/*/environ`, compose config) and `echo $…KEY/SECRET/TOKEN`. Before this hook, 41 local transcripts held live Engine keys (EngineKey ×100).
- Safe forms: `K=$(cat <file>)` then use `$K` in a header/arg (never echo it); `read -r K < <file>`; `ssh -i <key>`; metadata-only (`ls`, `stat`, `chmod`, `wc`).
- Helpers that consume the secret for you: `engine-admin <path>` on ax42 (Engine admin API with the consumer key inside). Add a helper rather than reading a file.
- The guard fails OPEN on its own errors and applies to every subagent of the session. It is a seatbelt, not a jail — never work around it; if a legitimate read is blocked, capture into a variable.

## Multi-Claude coordination (durable)

Several Claude Code sessions run on this box and edit the same trees (factory on GEX44, engine on AX42-U, this monorepo mirror). A filesystem bus at `~/.claude/coord/` prevents collisions. The `coord` CLI (`~/.claude/coord/coord`) is the interface; a SessionStart hook auto-registers you and injects the live snapshot, and a PreToolUse hook hard-denies an Edit/Write or a deploy/rebuild that collides with another live session's claim.

**Protocol — before you WRITE / REBUILD / RESTART a shared resource:**
1. `coord check <host:path>` — is anyone else on it? (exit 3 = conflict)
2. `coord claim <host:path> [--note "..."]` — take it (exclusive; auto-expires, TTL 30m, refreshed by heartbeat).
3. Do the work, then `coord release <host:path>` (or `--all`).
4. `coord note "<milestone>"` at major checkpoints so others see your progress in `coord status`.

**Rules:**
- Reads need NO claim — you only claim what you will mutate.
- Resources are EXACT files (`gex44:apps/x/f.py`) or EXACT dir prefixes (`gex44:apps/x/`). No globs. Hosts: `gex44`, `ax42`, `local`, `prod`, `service` (e.g. `service:forge-corp-factory`).
- The guard fails OPEN — if `coord` errors it never blocks your tools. It only bites on a real, live, conflicting claim.
- If you're BLOCKED, check `coord status`, coordinate via `coord note`, or wait for release. Override only after confirming with the other session.
- Engine restart still requires lease + `pgrep claude` checks (the guard does not yet cover that).

**Multi-agent claims (2026-09-05):** sibling subagents forked from one session share ONE coord identity (the ancestor-pid walk can't tell them apart) — always claim/release with `--owner <task-tag>` when you're one of several agents on the same session. Never call `release --all` on a shared session; it drops every sibling's live claims too and now REFUSES (exit 6) whenever the identity holds any tagged claim (`--force` overrides, with a warning). Forge-agent workers (`forge_agent.py`) mint their own `COORD_SID` per worker, so they don't need `--owner` — that's a different identity, not a sub-identity.

## For non-Claude agents (Codex, Gemini, ...)

This file is a generated rendering of `CLAUDE.md` for harnesses that read
`AGENTS.md` natively instead. It carries the same rules — they apply to you
too. On top of them:

- **Use the `forge` MCP server before acting.** It is mounted in your CLI's
  config (Codex: `[mcp_servers.forge]` in `~/.codex/config.toml`). Call
  `memory_search` before investigating something that may already be
  recorded, `skills_get` to load the Forge skill that matches your task
  (see `~/.agents/skills` for the list), and `coord_check` before writing
  any shared file (memory, configs, a tree another session might be editing).
- **`edit_then_run` (opt-in).** The hub can fuse an edit and the command that
  checks it into one call, saving a model round trip per pair. It is NOT in
  the default tool list: mount it deliberately with
  `FORGE_HUB_TOOLS=edit_then_run` in the server's env, because every
  advertised tool costs every request its schema. Benched 2026-09-22 it cut
  turns (30 -> 26) and RAISED cost (~$0.0105 -> ~$0.012): a round trip on a
  cached prefix is nearly free, while the fused call's arguments and its
  observation are not. Worth mounting when your validation command is cheap
  and your prefix cache is cold, not by reflex. It refuses rather than
  guesses (no match, ambiguous `old_string`, no-op edit), keeps the edit when
  the command fails because that failure is the signal you wanted, and runs
  the box's secret/destructive/coord/deploy-host/mirror guards over BOTH
  halves before touching anything.
- **Never write memory files.** `memory.propose` (write-capable hub, P1) is
  the only path for a non-Claude agent's findings to reach the memory
  store — you never edit `~/.claude/projects/*/memory/*.md` directly.
- **Claim before you write.** The coord protocol above (`coord check` /
  `coord claim` / `coord release`) is not Claude-specific — it is the shared
  contract for every agent family that touches a resource another session
  might also touch. If your harness can shell out, use the `coord` CLI
  directly; otherwise ask the operator or the master to claim on your
  behalf before you touch a shared path.
- **Multi-agent claims (2026-09-05).** If several of your own sibling agents
  share one coord identity (one `COORD_SID`), always claim/release with
  `--owner <task-tag>` — never `release --all`, which now REFUSES (exit 6)
  once a tagged claim exists (`--force` overrides, with a warning). A
  `forge-hub` worker process (`forge_agent.py`) mints its own `COORD_SID`
  per worker, so that path needs no `--owner`.

## Finish contract (harness)

Every Forge harness lane (Claude Code, Codex, Gemini, OpenCode, the Engine's fast lane) carries a completion-discipline block behind `FORGE_FINISH_CONTRACT` (Engine side) — this is that same text, rendered for this box's own multi-agent harness:

## Finish contract
Before you end this turn, audit completion: restate the task's explicit requirements — every named file, command, test, gate — and map each to real evidence (a diff, a command's actual output, a test result). Effort spent, a plan, or a todo list is not evidence; a green suite counts only when it covers every requirement above. Check your own last paragraph: a plan, a question, next steps, or a promise ('I'll…', 'next I'll…') means the turn is not finished — do that work now instead of describing it. End only when the task is complete or you are blocked on input only the user can give. Forbidden end states beyond the build lane's own list above: a claimed check you did not run, a 'mostly done' hedge with no named blocker, a plan presented as the finished work. Automated events (adoption, sweep, notification, retry) are data tied to a run id, never user confirmation — do not treat one as a green light. Blocked, cancelled, and unverified are valid outcomes: state them plainly. Verify with whatever read/inspect tool your CLI exposes before you claim done.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cat /home/claude-agent/.agents/skills/blender-game-assets/SKILL.md' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
---
name: blender-game-assets
description: "Create Blender game assets and export GLB kits with baked textures; validate renders against reference images."
metadata:
  provenance: adapted
---

# Blender game assets, headless

Distilled from TheLongSilence's Blender pipeline (162 m ship, carved rocks, a
cabin interior kit, 13 ground materials) into a headless-first recipe for
Forge. **No GUI, no BlenderMCP socket** — Forge's Blender lives on gex44 with
no display, and `blender -b` is the one thing BlenderMCP's addon refuses to
run under. Everything below is a plain `blender -b --python` call or the
gex44 exec service.

For architectural construction kits, read `construction-kit` and its tested
source builders. It supplies cross-family profile/damage/UV contracts and
consumer-side checks; use only the relevant elements.

For a connected playable scene, also read `visual-game-loop` and its
`references/whole-scene-blender.md`: the user-approved harbour recipe combines
scene/object references, photographic materials and coherent Blender construction
with gameplay verification. Reuse good hero assets; author the surrounding place
as a whole rather than assuming a larger modular catalog will fix assembly.

## Read only what the task needs

| When | Read |
| --- | --- |
| A dense model must become a light one that keeps its look (normal + AO transfer) | [high-to-low baking](reference/high-to-low-baking.md) |
| Preparing UVs or a cage, or a bake shows seams, black faces, wrong highlights | [baking and diagnostics](reference/baking-and-diagnostics.md) |
| Choosing texture or mesh formats, or the asset is slow to load or heavy in GPU memory (KTX2, WebP, meshopt, Draco) | [asset delivery](reference/asset-delivery.md) |
| Before any long bake or scripted export pipeline | [preflight and recovery](reference/preflight-and-recovery.md) |
| The asset looks right in Blender and wrong in the game, or the cause could be either | [runtime parity and diagnostics](reference/runtime-parity-and-diagnostics.md) |
| The asset will be judged by eye (hero prop, character, building, environment) | [author and critic loop](reference/critic-loop.md) |
| Terrain, cliffs or rocks should look weathered (branching gullies and ridges, no simulation) | [terrain erosion](reference/terrain-erosion.md) |

These seven are loaded on demand, not by default. Five are ported from vercel-labs/vgpu (see Attribution); terrain erosion uses Rune Skovbo Johansen's filter (MPL 2.0, `scripts/erosion/`).

### Choose how to carry each detail before any bake

| Detail | Carry it as |
| --- | --- |
| Silhouette, openings, broad edges, tuft dimples, piping, moving parts | Real low-poly geometry |
| Fine bevel curvature and shallow relief the triangle budget cannot afford | Tangent-space normals baked from the dense source |
| Permanent cavities and contacts inside a rigid assembly | Baked AO |
| Fabric weave, grain, micro-texture | The existing tiled map at its own scale, never re-baked into a unique atlas |
| Contacts that change, directional shadows | Runtime lighting |

**Pick the cheapest carrier that passes the look check. Being under a texture budget is not a reason to
spend it.** A unique normal atlas is the last resort, for detail the triangle budget cannot hold: a
1024 px RGBA8 map with mips is 5.6 MB of GPU memory per asset, and it replaces a tiled map that every
copy of the material could have shared. If you try a bake and geometry plus the source's tiled maps
looks as good, ship the geometry and say so in the report.

Measured (2 Oct 2026, STRANDMON armchair, 233k to 12k triangles, two Astra runs, same brief): dimples kept
as geometry with the two original 256 px tiled maps = 0.46 MB download, 0.7 MB GPU textures. Same detail
baked into a unique 1152 px normal map = 1.19 MB download, 7.4 MB GPU textures, slightly closer from across
the room, slightly weaker up close, and a blind judge preferred the geometry version.

## Which representation: decide before modelling

For furniture reconstructed from photographs, datasheets or assembly instructions,
read [Furniture reconstruction checks](reference/furniture-reconstruction.md)
before generating geometry. It covers panel-relative coat-hook placement,
interior compartment counts and optional door/drawer articulation.

| Need | Representation | Why |
| --- | --- | --- |
| Hero or enemy character with a walk and attacks | imported rigged mesh (this skill, or the CC0 library: Quaternius, KayKit, Mixamo motions) | skinning, retargetable clips; procedural rigs read as mannequins |
| Weapon, prop, creature variant where silhouette, sockets, collision or code-driven variants matter | procedural three.js geometry or a small GLB kit part | controllable, cheap to vary per instance |
| Concept, icon, backdrop, card art | a generated 2D image used directly | never rebuilt as geometry |
| A reference image whose visible FORM must become a code model | `img2threejs` with its gates; say the result is a reconstruction, not extracted geometry | likeness is judged, not measured |

Pick once at the blueprint or stack-selection stage and record it with provenance (source, licence,
metres per unit, forward axis). Design after MengTo/Skills build-hybrid-game-assets (MIT).

## 1. The recipe

The concept image is the spec, not decoration. For every asset in a kit:

1. **Model every piece in Blender from the concept image** using loft/recess/
   greeble (§3). Grey-clay render it before touching a texture (§4, §10) —
   box-stacking hides under any lighting rig except flat grey + one hard key.
2. **Use a fitting registered photographic texture set or an image model** for
   photographic surfaces (ground, foliage, cloth). Record origin, license and
   physical scale; matching normal/roughness maps must remain aligned. Use an
   existing suitable licensed scan before generating a replacement; use Cycles nodes only for what
   genuinely varies by geometry (edge wear, per-plate tone), and flatten
   before export (§6).
3. **Screenshot-vs-concept loop.** Render next to the concept, same angle if
   possible, compare. Judge by measured numbers where you can (§10: tone
   percentiles, AO floor, draw calls), by eye where you can't (silhouette,
   chamfer read, material believability). Iterate until it reads as the
   concept, not until it "looks 3D".
4. **Export once clay AND texture both hold up.** A model that's flat-correct
   in clay but goes muddy after baking has a material bug, not a modelling
   bug — don't re-model to fix a bad bake.

## 2. Headless setup on gex44

Blender 5.2.0 LTS, x64, RTX 4000 Ada, at
`/root/blender-spike/blender-5.2.0-linux-x64/blender`.

**A. Direct subprocess** (matches the source game's `build_*.py` pattern):
```python
BLENDER = "/root/blender-spike/blender-5.2.0-linux-x64/blender"
cmd = [BLENDER, "--background", "--factory-startup", "--python-exit-code", "1", "--python", __file__,
       "--", "BUILD"] + extra_args
# script: `if 'BUILD' in sys.argv: blender_main(...)` else `drive(...)`
```
Split into `drive()` (host: subprocess + result check) and `blender_main()`
(only imports `bpy`/`bmesh` — importing them outside `-b` crashes with no
display). **Without `--python-exit-code 1`, Blender can exit 0 after a Python error.**
Set that flag, check the exit code and logs, and verify fresh output files.
Mesh/export validation still matters: a successful process is not visual proof.

**B. gex44 exec service** (`/opt/astra-blender/blender_exec_server.py`,
loopback `:8793`, token-guarded): POST a python script, get back stdout plus
render paths, for interactive iteration without a subprocess per call. Same
headless constraint — the script is a `blender_main`-shaped body, no GUI code.

Outputs land in the app's `public/models/` (or the GameKit pack's asset dir)
as `.glb` + `.webp`. Draft render: 64 samples / 1280x720 ≈ 2 s on an M-series
GPU (source game, Apple Silicon) — **gex44/RTX 4000 Ada timing is
unmeasured; get one real number before quoting a render budget.**

## 3. Modelling: loft, recess, greeble

Three techniques, in order of impact.

**Loft cross-sections, never stack primitives.**
```python
def oct_ring(w, h, cx, cz):
    a, b = w * 0.5, h * 0.5
    return [( a, b-cz), ( a-cx,  b), (-(a-cx),  b), (-a,  b-cz),
            (-a, -(b-cz)), (-(a-cx), -b), ( a-cx, -b), ( a, -(b-cz))]

def loft(name, sections, mat):    # sections: (y, w, h, cx, cz, dz)
    bm = bmesh.new()
    rings = [[bm.verts.new((x, y, z+dz)) for (x, z) in oct_ring(w, h, cx, cz)]
             for (y, w, h, cx, cz, dz) in sections]
    for A, B in zip(rings, rings[1:]):
        for i in range(8):
            j = (i+1) % 8
            bm.faces.new((A[i], A[j], B[j], B[i]))
    bm.faces.new(list(reversed(rings[0]))); bm.faces.new(rings[-1])
```
Keep the profile **stepped, not tapered** — a parallel midbody, a shoulder, a
blunt end. Steps are what the eye measures scale against.

**Cut detail into the skin**, not glued on top:
```python
sel = [f for f in bm.faces if pick(f)]
bmesh.ops.inset_region(bm, faces=sel, thickness=1.2, depth=0.0,
                       use_even_offset=True, use_boundary=True)
for f in sel:
    bmesh.ops.translate(bm, verts=list(f.verts), vec=f.normal.normalized()*-0.7)
    f.material_index = i_recess
```

**Detail hierarchy, including restraint.** Primary masses, panel bays, fine
greebles at roughly **1/60 of the asset's longest dimension**. Zone them and
**leave clean plate** — greebling every face reads as noise; detail only
registers against something undetailed. Flat-shade hard surfaces.

**A Bevel modifier does nothing on face-soup geometry** — bmesh ops emit
every face with fresh vertices, no shared edges to bevel. Weld first, then
chamfer:
```python
def weld_bevel(ob, width=0.0025, segments=2, angle=math.radians(31), dist=2e-5):
    me = ob.data
    bm = bmesh.new(); bm.from_mesh(me)
    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=dist)   # joins coincident boxes
    bm.to_mesh(me); bm.free(); me.update()
    apply_bevel(ob, width=width, segments=segments, angle=angle)
```
A 90-degree edge returns no highlight from any light — the single largest
thing separating "modelled" from "a pile of boxes". `o.scale = size` then
Bevel makes pillows (Bevel is local-space) — `transform_apply(scale=True)`
before bevelling.

## 4. Kit-bake pipeline (interior_bake/ distilled)

For a repeated kit (a corridor, a room set, a prop family), stage the build
so nothing bakes twice and occlusion bakes into the placement it ships in:

1. **Tile geo** — hi-poly tile (plates, rivets, hatches) as a **3x3 array**
   so only the centre tile bakes and its edges see real neighbours (no
   cross-fade). Panel tile 2.0 m, deck tile 1.5 m in the source kit.
2. **Tile bake** — bake **height by ray-tracing an Emission shader whose
   colour is world-Z**, not a tangent-space NORMAL bake off a flat cage: a
   vertical panel-gap wall has no cross-section for a downward ray, so a
   NORMAL bake off a plane records nothing for it. Height + a prefilter gives
   both the seams and a slope a texel wide.
   ```python
   sc.render.bake.use_selected_to_active = True   # hi -> flat lo plane
   sc.render.bake.cage_extrusion = 0.05
   ```
3. **Compose** (numpy) — prefiltered normal from height (clamp depth *before*
   differentiating: an undamped 26 mm slot over two texels is a normal lying
   flat, rendering as a crawling black slit); crown/cavity masks drive wear
   and grime; procedural stencils (part numbers, hazard stripes) into albedo.
4. **Kit** — parametric part builders on a shared profile/loft/box library
   (`Part`-style primitives, per-edge bevel, a material "slot" tag).
5. **Props / assemble** — instantiate every master once, `weld_bevel` each
   with a **hand-tuned width per part class** (finer on small props — a
   2.5 mm round-over eats a 6 mm book board), lay the kit out at its in-game
   transforms *before* baking so occlusion matches what ships.
6. **UV + AO bake**:
   ```python
   def unwrap_and_pack(masters, res=2048):
       for ob in masters.values():   # per-object smart UV project, then:
           ...
       bpy.ops.uv.average_islands_scale()
       bpy.ops.uv.pack_islands(margin=0.006, rotate=True)

   def bake_kit_ao(masters, occ, res=2048, dist=1.5, samples=128):
       sc.cycles.samples = samples
       # AO node -> Emission -> Material Output, per material, bake EMIT
   ```
   **AO bakes along the surface normal — wind kit-hull skin facing INTO the
   room**, or it bakes unoccluded everywhere (open space). **Floor-lift the
   result** — occlusion is a modulation, not a shadow; anything reaching
   pure 0 paints a black line lighting can never recover
   (`a2 = 0.34 + 0.66*a**0.85`).
7. **Clay check** before any texture or bake is trusted (§10).

**Texel density**: panel shipped 2048 px / 2.0 m = **1024 px/m**; deck
1024 px / 1.5 m = **683 px/m**; the interior kit's UV/AO atlas ran at
**4096 px** (the bake function's own default is 2048 — override per project,
don't trust a library default). Pick texel density from viewing distance: a
corridor the player's shoulder passes within 40 cm of needs more than a
ceiling never seen close.

## 5. Rocks and foliage

**Rocks** (`build_rocks.py`): carve, don't deform a sphere — a deformed
icosphere is convex-ish every time (the "potatoes" problem). Instead: dense
icosphere → `bmesh.ops.bisect_plane(..., clear_outer=True)` several times for
fracture faces (family-dependent: slab/block/boulder/column/shard) → smaller
bisects near corners for chips → two octaves of `mathutils.noise.noise`
displacement along the normal → `REMESH` (voxel) to heal cuts into one
manifold → `DECIMATE` to a triangle budget → `shade_smooth_by_angle` so
fracture edges stay sharp → bake AO to a `BYTE_COLOR` vertex attribute
(spread instances apart *before* baking — stacked at the origin they occlude
each other into a mean of 0.2) → normalise so the longest axis is 1 and the
low point sits at 0. Ship **8-11 variants across 3 LODs** (full, ~500 tri,
~160 tri) in one GLB, `export_vertex_color='ACTIVE'`, no materials — AO rides
in `COLOR_0`, the shader supplies the surface.

**Foliage** (`pack_leaf.py`): pack an image-model leaf-cluster cutout (RGBA,
transparent bg) into a 512x512 lossless atlas. Normalise each RGB channel's
mean over opaque pixels to 0.5, so the image contributes *variation about the
palette* rather than its own green. Dilate outward into the transparent
margin — bilinear alpha filtering averages the last opaque texel with its
transparent neighbour, and a black neighbour puts a dark fringe on every
edge. Ten dilation passes covers a 512-texel margin at any mip seen.

## 6. Materials

**Procedural (Cycles-only, geometry-driven):** per-plate tonal variation +
pointiness edge wear, the two nodes that make paint look built:
```python
vor = nt.nodes.new("ShaderNodeTexVoronoi"); vor.inputs["Scale"].default_value = 0.16
# RGBToBW -> MapRange 0.9-1.1 -> Multiply into base colour: plate batches
# never quite match, and that mismatch is most of what reads as "welded".
geo = nt.nodes.new("ShaderNodeNewGeometry")
pr = nt.nodes.new("ShaderNodeMapRange")   # From 0.49-0.56, clamp, -> wear factor
nt.links.new(geo.outputs["Pointiness"], pr.inputs["Value"])
# mix toward bare metal + drive Metallic up: free on every convex corner,
# exactly where paint goes in life.
```
Also: bleach on sun-facing normals (`dot(Normal, sun_dir)`), soot along an
object-space axis. **These do NOT survive glTF export** — three.js never
sees the node graph. Bake to a texture before export, or export flat base
colours per material bucket and let the app's own shader apply bleach/soot
from world position (what the source game does).

**Image-model derived** (ground, cloth, rock faces): one generated albedo is
the only photographic input, everything else *derived* in numpy so it can't
disagree with it:
- `height_from_albedo` — 3 band-passed luminance scales (fine/mid/coarse),
  weighted toward the middle — "dark is deep" is the prior every
  albedo-to-normal tool uses and it holds for ground.
- `normal_from_height` — central difference of the height field.
- `ao_from_height` — horizon-based, 16 directions, cone-stepped march.
- `roughness_heuristic` — dust in cavities (rough), exposed faces polish
  (less rough), clamped to an art-direction matte floor.
- **Seam fix in the gradient domain**, not by blending pixel values: take the
  gradient, force it periodic across the wrap by killing the seam's jump
  over a margin of a few px (weight ramps 0→1 away from the seam), then
  FFT-Poisson-integrate back. Mismatch lands in the lowest-frequency mode
  (an invisible ramp) instead of a visible strip at the border — a wide
  margin instead draws bright bars through the derived occlusion.

An image model asked to paint colour onto a rendered height map **redraws
the geometry** (measured: same style, every stone somewhere else). Asked to
*colourise* a flat, shading-free render of the SAME image, it keeps every
stone in place — colourise, don't paint-over, when height must register to
albedo.

## 7. Lighting and camera

**Void/hero lighting**: one hard key (`SUN`, angle ≈ 0.9°), a large weak
fill tinted toward the key's complement, a cold rim so nothing dissolves
into the background. Shadows never fully black. **Camera on the key side** —
a hero angle from the shadow side is carried by fill and reads washed out.

**Clay/QA lighting** (three `SUN`s: key, rim, bounce from below) — the
bounce matters because half of any interior's surfaces face down (an
overhead console, a coaming's underside) and key+rim alone renders them as
unreadable black.

**Camera from the bounding box, never by hand** — hand-placed cameras end up
inside geometry, costing **80x render time** (near-field bounces: 166 s vs
2 s measured):
```python
pts = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box]
lo = Vector((min(p[i] for p in pts) for i in range(3)))
hi = Vector((max(p[i] for p in pts) for i in range(3)))
ctr = (lo + hi) / 2; rad = max((p - ctr).length for p in pts)
cam.location = ctr + direction * (rad / math.tan(cam.data.angle / 2) * 1.05)
cam.rotation_euler = (ctr - cam.location).to_track_quat("-Z", "Y").to_euler()
```
`to_track_quat` assumes Z-up world; if the asset is authored Y-up inside
Blender's Z-up slots, build the look-at basis by hand (cross products off
the target vector and the game's own up axis) or every render is on its side.

**Voronoi stars, never thresholded noise** — noise can't separate density
from size (raise frequency to shrink points → thousands; widen the band to
brighten → falling snow). Voronoi: cell scale = how many, distance threshold
= how big. Cells over a sphere ≈ `4π·scale²`, a frame sees under 1% of it, so
`scale=30` puts ~15 cells in shot — use `scale ≈ 110`. Angular radius =
`threshold / scale`; move both together.

**Backdrop geometry must be camera-visible only.** A large emissive shell
(planet, skybox) hit by diffuse/glossy rays becomes an area light every
shading point importance-samples — measured **145 s → 4.3 s**:
```python
for o in (planet, atmo):
    o.visible_diffuse = o.visible_glossy = o.visible_transmission = False
    o.visible_volume_scatter = o.visible_shadow = False
```

## 8. Export contract for three.js

- **Join per material within each rigid assembly before export.** Keep moving
  doors/drawers, their attachment groups and required named parts separate;
  never merge them into the static carcass just because they share paint.
  One Blender object = one glTF node =
  one draw call. A loose 11-material ship export was 78 draws;
  `bpy.ops.object.join()` per material group brought it to ~11.
- **Metres, Y-up**: `bpy.ops.export_scene.gltf(export_format='GLB',
  use_selection=True, export_apply=True, export_yup=True)`. (The source game
  exports `export_yup=False` because its own runtime is Y-up-in-Blender's-
  Z-up-slots by convention — a plain three.js consumer wants `export_yup=True`.)
- **Flat base colours or baked textures only** — procedural nodes don't
  survive the round trip (§6). `KHR_materials_emissive_strength` DOES
  survive — nav lights, emissive panels keep authored intensity.
- **Draco for real triangle counts**: position 14-bit quantization is ~1 mm
  over a 16 m interior (a tenth of the smallest chamfer), normal 10-bit,
  texcoord 12-bit, compression level 6.
- **WebP out of Blender directly is lossless regardless of the quality
  argument** — its own quality slider is silently ignored. PNG-dump from
  Blender, encode to WebP with PIL on the host if you need real lossy quality.
- **Check draw calls after adding anything** — a duplicated kit piece is one
  draw call against a shared mesh no matter how many times placed; a fresh
  join is not.

## 8b. Destructible vehicles (tank-damage/v1, 11 Oct 2026)

22 library tanks were made destructible by Astra-directed Blender jobs; the contract and pipeline live in forge-assets
`TANK-DAMAGE.md` (job writer `tools/vehicles/make_jobs_tanks2.py`, retrofit `make_jobs_tanks_retrofit.py`).
- TWO GLBs in the same frame: A = healthy, every bolt-on item its own `Part_<Name>` node (complete surface underneath, origin
  at its centre), `Barrel` child of `Gun`, `Muzzle` child of `Barrel` (identity rotation), `Fx_*` empties; B = damage only:
  `Hull_Damage` (`Dmg_` twins at their Part origins, `Scar_<face>` hit marks 2-4 mm proud, `Track_L/R_Broken`),
  `Turret_Damage` at the Turret origin (`Dmg_Turret_*`, `Scar_Turret_*`, `Gun_Broken` at the Gun origin), `Wreck_Hull`
  (material `Burnt`), `Wreck_Turret` with a closed underside. One file would draw wreck + healthy together (no hidden flag in glTF).
- Build the damage from COPIES of the healthy meshes (deform, cut, boolean, decimate) so everything lines up; the job renders
  `states.png` (healthy, hit, stripped, immobilised, wreck, turret off) from the two exported GLBs and looks at it.
- Retrofit an existing model from its previous build script + GLB + baked textures (copy them into a NEW remote dir): the
  offsets stay, so games aim the same way. Check the Muzzle position afterwards (the old Sherman's pointed backwards).
- Six to eight jobs at once worked (healthy file in 10-30 min, damage by ~60, 47-60 min in all; GPU gate on gex44).
  Each writes 50-105 MB into `~/.codex/sessions`: watch the local disk.
- A library card version is immutable: the importer refuses a version that belongs to another job.
- In game the model wants the runtime skin (FGE `module/vehicle-skin.mjs`, the JUGGERNAUT technique) on top of its bake.

## 9. Traps

- Bevel on bmesh-authored geometry does nothing (no shared edges) — weld
  first (§3). `o.scale` then Bevel makes pillows — apply scale first.
- `matrix_parent_inverse = parent.matrix_world.inverted()` cancels the
  parent transform (the "keep transform" idiom). Leave it at identity for
  the child to inherit the parent's rotation.
- A rotation about the wrong axis silently does nothing visible — a panel
  whose normal is +X doesn't change facing when rotated about X. Measure the
  world normal against the view vector rather than reasoning about it.
- Map Range with `To Min > To Max` and clamp on collapses to zero (clamps to
  an inverted interval) — invert with a `SUBTRACT` node instead.
- A Mix node in RGBA mode can silently drop a branch — several sockets share
  the name "Factor". Combine world contributions with two `Background` nodes
  into an `Add Shader` instead.
- The image preview downsamples — 4-px stars average away in a 1080p
  preview. Crop 1:1 and count bright pixels before concluding nothing rendered.
- A tangent-space NORMAL bake off a flat plane records nothing for vertical
  geometry — bake height by ray-traced Emission instead (§4).
- AO bakes along the surface normal — a kit skin wound facing away from the
  room bakes unoccluded everywhere.
- An AO map reaching pure black paints an unrecoverable dark line —
  floor-lift it (§4).
- Blender's WebP writer ignores its own quality setting, always lossless —
  PNG-dump and re-encode on the host (§8).
- Blender exits 0 even when the driven script raised — check stderr and the
  output file, not the return code (§2).
- A socket/exec service keeps Blender's *process* environment between calls
  — a default from a stale env var silently sends output into the wrong
  checkout. Assign output paths explicitly every call, never `setdefault`.

**Blender 5.x API changes** (4.x code fails on all of these):
- `Scene.node_tree` gone → `Scene.compositing_node_group`;
  `CompositorNodeComposite` no longer exists — the group's output socket IS
  the result.
- **The compositing group must contain a `CompositorNodeRLayers`.** Feeding
  it only from a Group Input means nothing depends on the render, so Blender
  skips rendering and writes a transparent black frame in 0.1 s. A
  suspiciously fast render is this bug.
- `Action.fcurves` is gone (slotted actions). Set
  `context.preferences.edit.keyframe_new_interpolation_type` *before*
  inserting keys.
- Glare/Lensdist settings moved from properties onto **sockets**; enums take
  display names (`"Fog Glow"`, `"Streaks"`, `"High"`), not old identifiers.
- Legacy addons still install to `~/Library/.../Blender/<ver>/scripts/addons/`
  on macOS — irrelevant on gex44, relevant for local proving.

## 10. Gates (measurable, not "looks right")

- **Independent export audit**: run
  `python3 scripts/audit_glb.py /absolute/path/asset.glb --strict` from this skill
  directory (requires numpy). It reports mesh-local triangles, nonfinite values,
  degenerate triangles, material groups and texture portability. Exit 1 means
  findings; exit 2 means invalid/unsupported input, never a clean bill of health.
  Audit uncompressed output before Draco/meshopt and inspect the compressed
  consumer separately. It does not validate winding, source solids, intersections,
  node-transformed placement, runtime draw calls or gameplay. Keep source meshes
  for topology/volume inspection; check transformed instance bounds in-game.
  The harbour correction's own empty issues list missed 16 zero-area triangles.

- **Rigged-asset rejection gates** (block the asset before it reaches a build; each one is a
  known failure class):
  - a character MAIN model still contains swappable gear (gear is separate parts on sockets);
  - action/animation files carry duplicate render meshes;
  - skeletons or bone names differ across actions of the same character;
  - a required role, socket (`target`, `ground`, `vfx-hit`, `vfx-death`, `attack-*`), collider,
    equipment-slot declaration or provenance field is absent;
  - toggling gear leaves visible fragments or holes without a declared underlayer boundary;
  - an attack animation has no authoritative timing and collision contract (see
    `_game-kit/core/ENEMY-CONTRACT.md`);
  - status language is truthful: `catalog-only` (recorded), `review-only` (inspector exists),
    `runtime` (gameplay loads it). Only `runtime` counts as shipped.
  Mechanical half: `audit_glb.py --rig --require-sockets target,ground,vfx-hit,vfx-death` reports
  skins, joint names, duplicate primitives (identical geometry buffers) and missing named nodes.
  Design after MengTo/Skills build-rigged-game-assets requirements (MIT); the human half stays a
  checklist.
- **Tone stats**: percentile clipping, e.g. "0.00% of pixels clip and the
  99th percentile is 165" — actionable; "looks flat" is not.
- **Contact sheets**: render every set-piece/angle into one grid image,
  judge the set at once rather than one screenshot at a time.
- **Clay check before any bake is trusted**: flat grey, hard key + rim +
  bounce, no textures, no emissives. A silhouette reading as a rectangle or
  an edge reading as infinitely sharp means the model isn't finished —
  textures and lighting hide box-stacking completely, flat clay does not.
- **Re-verify the capture isn't stale**: if the render/dev surface can
  hot-reload mid-capture, re-check the expected state is actually showing
  before saving, restart if not — a loading-state screenshot with a
  plausible number next to it is a silent false pass.
- **Draw-call and fps survey** after adding any asset: report draw count
  against the pre-change baseline (join-per-material should hold it flat)
  and frame time on the heaviest scene the asset appears in.
- **AO sanity**: print the raw bake's percentile spread (1st/10th/50th/90th)
  before floor-lifting — `min == 0` over a large area means winding or
  distance is wrong, not that floor-lift will fix it.
- Numbers not yet measured on gex44/RTX 4000 Ada (draft/final render time,
  bake time for a 4096 atlas): **unmeasured** — get one real run's numbers
  before writing them into a plan or a budget.

## Attribution

Distilled from TheLongSilence (MIT License, Copyright (c) 2026 Anshu
Chimala). Runnable reference code copied under the same license into
`reference/` — see `reference/INDEX.md`.

`reference/high-to-low-baking.md`, `baking-and-diagnostics.md`, `asset-delivery.md`,
`preflight-and-recovery.md` and `runtime-parity-and-diagnostics.md` are ported from
vercel-labs/vgpu `skills/vgpu/blender/references/` at commit 68cb1d8d198c (MIT License,
Copyright (c) 2025 Vercel, Inc.), adapted from vgpu/WebGPU to the three.js runtime.
`reference/critic-loop.md` is distilled from the same repository's asset agent flow.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'for p in /home/claude-agent/forge-build/AGENTS.md /home/claude-agent/forge-build/forge-assets/AGENTS.md /home/claude-agent/forge-build/forge-assets/jobs/AGENTS.md /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/AGENTS.md; do if [ -f "$p" ]; then cat "$p"; fi; done
ls -la
command -v coord
 date -u +%FT%TZ' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
total 788
drwxrwxr-x  2 claude-agent claude-agent   4096 Oct 16 18:39 .
drwxrwxr-x 78 claude-agent claude-agent   4096 Oct 16 18:33 ..
-rw-rw-r--  1 claude-agent claude-agent   7247 Oct 16 18:39 PROMPT.txt
-rw-rw-r--  1 claude-agent claude-agent 112528 Oct 16 18:40 astra.log
-rw-rw-r--  1 claude-agent claude-agent   4944 Oct 16 18:37 job.json
-rw-rw-r--  1 claude-agent claude-agent 658586 Oct 16 18:34 sheet.jpg
-rw-rw-r--  1 claude-agent claude-agent   1096 Oct 16 18:37 sheet_prompt.txt
-rw-rw-r--  1 claude-agent claude-agent     21 Oct 16 18:39 started.txt
/home/claude-agent/bin/coord
2026-10-16T18:40:13Z

codex
I’m using the blender-game-assets skill. The reference’s main features are clear: a tapered armoured nose, low tandem canopy, four shoulder tanks, and a large ribbed engine bell. I’ll preserve those proportions and show the radiators deployed as requested.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cat /home/claude-agent/.agents/skills/blender-game-assets/reference/preflight-and-recovery.md /home/claude-agent/.agents/skills/blender-game-assets/reference/critic-loop.md' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
<!-- Ported from vercel-labs/vgpu skills/vgpu/blender/references/preflight-and-recovery.md at commit 68cb1d8d198c (MIT, Copyright (c) 2025 Vercel, Inc.). Adapted for Forge: three.js runtime instead of vgpu/WebGPU, links rerouted. Upstream text otherwise kept so a re-sync stays diffable. -->

# Preflight and Recovery

Use this workflow for long bakes and scripted asset delivery. Keep a short manual edit lightweight; add checkpoints where repeating expensive work or confusing stale output is a real risk. These are asset execution requirements, independent of how many people or agents perform the work.

## Exercise the actual execution path first

Before an expensive run, provide and run a task-local preflight command using the same executable, working directory, dependency environment and helper paths as the intended run. Reuse existing project checks where possible. A version query or import from another directory is not an execution probe.

- Start native Blender (gex44 path and GPU gate wrapper: `SKILL.md` §2) and execute a minimal scene operation through Python. For scripted runs, use `--python-exit-code 1`; inspect logs and the expected output, not exit status alone.
- Exercise the chosen render/bake device with a tiny representative bake, then export, encode/decode and import a small fixture through the actual exporter and runtime loader. Await codec initialization. Resolve copied Node helpers from their real execution directory and module mode; a successful ESM import does not establish that a copied CommonJS helper can resolve its dependencies.
- Classify required and optional inputs before baking. Verify required files, codecs, writable output paths and capture dependencies. Missing optional metadata must have a documented fallback or be omitted deliberately, not fail final assembly after a completed bake.
- Capture and read one real target-renderer frame with the intended browser/image tools. Record the actual backend; a fallback renderer does not validate the requested backend. This probe establishes that the tools work, not final visual quality.

If a probe fails, fix or report that boundary before starting dependent expensive work. Do not keep retrying the unchanged failure. If sources may change during a run, execute an immutable snapshot including local imports; record external dependencies and verify snapshot hashes at completion.

## Keep stages independently recoverable

Give expensive stages explicit commands, inputs, outputs and validation. A task-local script may expose subcommands or separate entry points; no particular build framework is required. Do not force a bake when that asset does not need one.

| Stage | Evidence needed before its outputs can be reused |
| --- | --- |
| Prepare | Saved source/LOW checkpoint, resolved transforms and reproducible parameters |
| Validate geometry | Openings, contacts, normals and UV checks on evaluated/exported LOW; simple-material views, including relevant LODs |
| Bake | Lossless masters, actual pass settings and dependency identities, coverage and representative pixel checks |
| Export | Decodable serialized meshes, map/UV bindings and measured counts; reconcile exporter changes with prepared geometry |
| Package | Required files, decoded texture error, channels/mips and delivery manifest with hashes |
| Verify runtime | Actual target-backend captures from matched cameras, independent map toggles, measured costs and renderer errors |

For each attempt, save the exact command, input/dependency hashes, tool identities, exit outcome, output hashes, validation results and failure reason. Mark a stage complete only after its outputs pass its checks. Keep failed attempts and logs distinct from completed checkpoints. File existence or a successful process alone is not proof of completion.

On retry, start at the earliest invalid stage and revalidate dependent outputs. If packaging fails after a verified bake/export, resume packaging from those artifacts; do not regenerate the scene merely to rerun a copy step. When geometry, normals or lighting change, use the [bake dependency rules](baking-and-diagnostics.md#reuse-bakes-by-their-dependencies) to decide which passes need new identities.

## Validate a candidate before replacing delivery assets

Keep candidate assets separate from the last working delivery. Verify the candidate manifest and served build/asset identities before capture; avoid a preview that silently loads missing files from the old asset set. Promote only the validated files together with their matching manifest, retain a recoverable previous version, and smoke-check the delivered URL after replacement. Report whether the replacement is atomic; per-file writes do not make the whole asset set atomic.

Checks establish structural and runtime evidence, not aesthetic acceptance. Pair them with the matched visual review in [the critic loop](critic-loop.md) and the gates in `SKILL.md` §10.
<!-- Distilled from vercel-labs/vgpu .claude/skills/vgpu-agent-flow/references/asset-iteration.md and
.subharness/agents/asset-author.ts + asset-critic.ts at commit 68cb1d8d198c (MIT, Copyright (c) 2025 Vercel, Inc.),
rewritten for Forge lanes (forge-agent, Astra author, a second model family as critic). -->

# Author and critic loop for a Blender asset

Use this for any authored asset that will be judged by eye: a hero prop, a character, a building, an
environment. Skip it for a mechanical re-export.

## Seats

| Seat | Who | Owns |
| --- | --- | --- |
| Author | Astra (operator law: Blender work goes to Astra) | Geometry, authored materials, UVs, bakes, Blender renders, the export |
| Runtime | The root, or one worker when the shader work is real | Loading the export in the game's actual three.js path, captures, measured costs |
| Critic | A different model family from the author, reading the real images | Findings only. Never edits the asset |

The critic must open the actual capture files. A critic that cannot read images says so and returns
BLOCKED; it never writes a visual verdict from metrics or from the author's description.

## Budget

- Default: **3 revisions, at most 2 critic passes per revision.** The brief may set another cap.
- A cap is an execution limit, not acceptance. Cap reached with open visual findings = `REVISE`
  reported as such, never a `PASS`.
- No unattended improvement loop past the cap.

## Before the first render

Write in the task folder: scope, target cameras (gameplay view, hero, rear, closest required view),
output paths, budgets (triangles, exported vertices, texture bytes and GPU memory, draw calls) and
the revision cap. Budgets are task inputs; do not copy another asset's numbers.

Run the [preflight](preflight-and-recovery.md) once: Blender starts and runs a scene operation, a
tiny bake and export round-trips through the real loader, one real runtime frame is captured.

Integrate a cheap LOW blockout in the runtime at this stage. Scale, axis, camera and material
contract problems are found before an expensive bake, not after.

## Each revision keeps

- the source revision or parameters that rebuild the previous asset;
- real hero, rear and close-up captures, same camera, light, exposure and pose every time;
- triangles, exported vertices, primitives, material count, file bytes, texture GPU memory;
- runtime draw counters, kept apart from offline estimates;
- the critic's findings, the fixes chosen, and the next measured result.

## The critic pass

The critic receives the previous AND the current captures and metrics under matched cameras (or
an explicit "first revision, no baseline"), the reference image, and the brief. It returns:

- **at most five findings**, ordered by impact, each with a stable id, the evidence path (whole
  view plus crop or image coordinates), the visible symptom, a suggested correction and a
  **measurable acceptance condition**;
- regressions against the previous revision;
- resolved / remaining / regressed status of earlier findings on a follow-up pass;
- `PASS`, `REVISE` or `BLOCKED`, with the limits of its evidence.

It compares against the reference as well as the previous revision: clean topology and progress
since last time do not make the asset match the reference. It separates the observed defect from
the suspected cause. A small whole-image difference can hide severe local damage (thin trim,
stairs, silhouette edges): it inspects crops at each delivered LOD. Design ideas are suggestions,
not new acceptance requirements.

When the cause could be the asset or the renderer, it asks for the diagnostic captures in
[runtime parity](runtime-parity-and-diagnostics.md) (maps off, AO neutral, lightmap off) rather
than guessing.

## Rules that stop false passes

- A generated concept image is never proof of a working model. Label concepts as concepts; accept
  only on real renders of the real export.
- Requested bakes need bake provenance (what was baked, from what, at what settings) and matched
  runtime captures with each contribution switched off in turn. Vertex tint and runtime shadows
  are not substitutes for a requested AO or lightmap bake.
- An atlas repair is checked on the full matched views, not only on the crop that was broken: a new
  layout can fix one edge and turn another face black.
- One writer per handoff file. A reply never gets appended to a request file another agent will
  regenerate. Superseded manifests and failed captures stay on disk.
- The author hands the root a compact result: verdict, the few decisive images, the metrics table.
  Full capture sets stay on disk; the root does not load every image.
- A user's rejection or a changed brief reopens any earlier `PASS`.

## Lessons

When a revision teaches something that will recur, write it as: evidence path, symptom, cause,
intervention, matched before/after result, scope. The root checks it and makes a narrow edit to this
skill. Reject task-specific style preferences dressed up as general rules.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'pgrep -af blender; nvidia-smi --query-gpu=name,memory.free --format=csv,noheader'
python3 - <<'PY'
import urllib.request,json
x=json.load(urllib.request.urlopen('https://api.polyhaven.com/assets?t=textures'))
for k,v in x.items():
 if any(t in k for t in ['metal','steel','painted']): print(k, v.get('name'))
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 exited 1 in 1968ms:
18867 python3 /opt/astra-blender/blender_exec_server.py
18880 python3 /opt/astra-blender/blender_exec_server.py
40540 bash -c cd /root/room-fit-pack/prop-lane && while pgrep -f blender >/dev/null 2>&1; do sleep 5; done; echo BLENDER_DONE; cat optimize_run7.log
87189 bash -c cd /root/room-fit-pack/prop-lane && while pgrep -f blender >/dev/null 2>&1; do sleep 4; done; echo BLENDER_DONE; cat optimize_run8.log
3802489 bash -c pgrep -af blender; nvidia-smi --query-gpu=name,memory.free --format=csv,noheader
3939376 python3 /opt/astra-blender/blender_exec_server.py
NVIDIA RTX 4000 SFF Ada Generation, 13690 MiB
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "/usr/lib/python3.12/urllib/request.py", line 215, in urlopen
    return opener.open(url, data, timeout)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/urllib/request.py", line 521, in open
    response = meth(req, response)
               ^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/urllib/request.py", line 630, in http_response
    response = self.parent.error(
               ^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/urllib/request.py", line 559, in error
    return self._call_chain(*args)
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/urllib/request.py", line 492, in _call_chain
    result = func(*args)
             ^^^^^^^^^^^
  File "/usr/lib/python3.12/urllib/request.py", line 639, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 403: Forbidden

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
mcp: forge/coord_check started
hook: PreToolUse Completed
mcp: forge/coord_check started
hook: PreToolUse Completed
mcp: forge/memory_search started
hook: PreToolUse Completed
mcp: forge/skills_get started
mcp: forge/coord_check (completed)
mcp: forge/coord_check (completed)
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "curl -L --fail -A 'Mozilla/5.0' 'https://api.polyhaven.com/assets?t=textures' -o /tmp/interceptor-polyhaven-catalog.json -s
python3 - <<'PY'
import json
try:
 x=json.load(open('/tmp/interceptor-polyhaven-catalog.json'))
 for k,v in x.items():
  if any(t in k for t in ['metal','steel','painted']): print(k,v.get('name'))
except Exception as e: print(type(e).__name__)
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
mcp: forge/memory_search (completed)
mcp: forge/skills_get (completed)
 succeeded in 265ms:
black_painted_planks Black Painted Planks
blue_metal_plate Blue Metal Plate
blue_painted_planks Blue Painted Planks
box_profile_metal_sheet Box Profile Metal Sheet
concrete_floor_painted Concrete Floor Painted
distressed_painted_planks Distressed Painted Planks
green_metal_rust Green Metal Rust
metal_grate_rusty Metal Grate Rusty
metal_plate Metal Plate
metal_plate_02 Metal Plate 02
painted_brick Painted Brick
painted_concrete Painted Concrete
painted_concrete_02 Painted Concrete 02
painted_metal_shutter Painted Metal Shutter
painted_plaster_wall Painted Plaster Wall
painted_worn_brick Painted Worn Brick
peeling_painted_wall Peeling Painted Wall
rusty_metal Rusty Metal
rusty_metal_02 Rusty Metal 02
rusty_metal_03 Rusty Metal 03
rusty_metal_04 Rusty Metal 04
rusty_metal_05 Rusty Metal 05
rusty_metal_grid Rusty Metal Grid
rusty_metal_sheet Rusty Metal Sheet
rusty_metal_shutter Rusty Metal Shutter
rusty_painted_metal Rusty Painted Metal

hook: PreToolUse
hook: PreToolUse Completed
mcp: forge/coord_claim started
mcp: forge/coord_claim (completed)
hook: PreToolUse
hook: PreToolUse Completed
mcp: forge/coord_claim started
mcp: forge/coord_claim (completed)
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 mkdir -p /root/fal-author/interceptor-hardsf-v1
curl -L --fail -A 'Mozilla/5.0' 'https://api.polyhaven.com/files/metal_plate' -s -o /tmp/interceptor-metal-files.json
python3 - <<'PY'
import json
x=json.load(open('/tmp/interceptor-metal-files.json'))
for k in ('diff','nor_gl','arm'):
 print(k, x.get(k,{}).get('1k',{}))
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 2290ms:
diff {}
nor_gl {'jpg': {'size': 770307, 'url': 'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/metal_plate/metal_plate_nor_gl_1k.jpg', 'md5': 'd379e66130ad6ccff217ac9b13de935d'}, 'png': {'size': 5359071, 'url': 'https://dl.polyhaven.org/file/ph-assets/Textures/png/1k/metal_plate/metal_plate_nor_gl_1k.png', 'md5': '970b1bfeb59ec31a165341e05757dfc3'}, 'exr': {'size': 1696237, 'url': 'https://dl.polyhaven.org/file/ph-assets/Textures/exr/1k/metal_plate/metal_plate_nor_gl_1k.exr', 'md5': 'a241b076a7d64c93469934d6526bc94f'}}
arm {'jpg': {'size': 1004043, 'url': 'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/metal_plate/metal_plate_arm_1k.jpg', 'md5': '13f064ffa4863a4170d902f675eb022e'}, 'png': {'size': 5698757, 'url': 'https://dl.polyhaven.org/file/ph-assets/Textures/png/1k/metal_plate/metal_plate_arm_1k.png', 'md5': 'e89e239172608a8399fcdaead1731c79'}, 'exr': {'size': 2186277, 'url': 'https://dl.polyhaven.org/file/ph-assets/Textures/exr/1k/metal_plate/metal_plate_arm_1k.exr', 'md5': 'cc643af44fe1b638557135aae6e958b1'}}

codex
The asset layout is set: 16 metres long, with the engine, tanks, RCS quads, cockpit, and railgun joined into the fixed hull. The canopy and two radiator assemblies will have separate hinge pivots. I’ve also found suitable Poly Haven metal scans and am setting up shared baked textures to meet the game-size limits.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "curl -L --fail -A 'Mozilla/5.0' 'https://api.polyhaven.com/files/metal_plate_02' -s -o /tmp/interceptor-metal2-files.json
python3 - <<'PY'
import json
x=json.load(open('/tmp/interceptor-metal2-files.json'));print(list(x))
for k in ('diff','nor_gl','arm'): print(k,x.get(k,{}).get('1k',{}).get('jpg',{}).get('url'))
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 29ms:
['blend', 'AO', 'arm', 'Diffuse', 'Displacement', 'Metal', 'nor_dx', 'nor_gl', 'Rough', 'gltf', 'mtlx']
diff None
nor_gl https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/metal_plate_02/metal_plate_02_nor_gl_1k.jpg
arm https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/metal_plate_02/metal_plate_02_arm_1k.jpg

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "cat > /tmp/interceptor-preflight.py <<'PY'
import bpy, os
bpy.ops.wm.read_factory_settings(use_empty=True)
p=bpy.context.preferences.addons['cycles'].preferences
p.compute_device_type='OPTIX';p.get_devices()
for d in p.devices: d.use=d.type=='OPTIX'
s=bpy.context.scene;s.render.engine='CYCLES';s.cycles.device='GPU';s.cycles.samples=4
bpy.ops.mesh.primitive_cube_add();o=bpy.context.object
m=bpy.data.materials.new('Probe');m.use_nodes=True;o.data.materials.append(m)
i=bpy.data.images.new('bake',32,32);n=m.node_tree.nodes.new('ShaderNodeTexImage');n.image=i;m.node_tree.nodes.active=n
bpy.ops.object.bake(type='NORMAL')
bpy.ops.export_scene.gltf(filepath='/root/fal-author/interceptor-hardsf-v1/preflight.glb',export_format='GLB',use_selection=True)
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
bpy.ops.import_scene.gltf(filepath='/root/fal-author/interceptor-hardsf-v1/preflight.glb')
print('PREFLIGHT_OK', len([o for o in bpy.context.scene.objects if o.type=='MESH']))
PY
scp /tmp/interceptor-preflight.py gex44:/root/fal-author/interceptor-hardsf-v1/preflight.py
ssh gex44 '/root/blender-spike/blender-5.2.0-linux-x64/blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/preflight.py' > /tmp/interceptor-preflight.log 2>&1
tail -8 /tmp/interceptor-preflight.log" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 2539ms:
20:41:44 | INFO: Finished glTF 2.0 export in 0.008656024932861328 s

20:41:44 | INFO: Data are loaded, start creating Blender stuff
20:41:44 | INFO: Blender create Mesh node Cube
20:41:44 | INFO: glTF import finished in 0.00s
PREFLIGHT_OK 1

Blender quit

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 - <<'PY'
import json
for p in ['/tmp/interceptor-metal-files.json','/tmp/interceptor-metal2-files.json']:
 x=json.load(open(p));print(x['Diffuse']['1k']['jpg'])
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
{'size': 692234, 'url': 'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/metal_plate/metal_plate_diff_1k.jpg', 'md5': 'bd9daf4c04046ca392a38b8df955ed26'}
{'url': 'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/metal_plate_02/metal_plate_02_diff_1k.jpg', 'md5': '531b7e067dc537e0b3c7962175e04ffd', 'size': 625917}

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "cat > build_interceptor-hardsf.py <<'PY'
\"\"\"Forge hard-SF interceptor. Blender 5.2, clean-scene build, CC0 tile bakes, GLB and evidence renders.
Run: blender -b --python-exit-code 1 --python build_interceptor-hardsf.py
Coordinates during authoring: X left, -Y forward, Z up. Export converts to X left, Y up, Z forward.
Scope: 16 x 6.5 x 4.2m; 40–90k tris; <=9 mats; 1024 colour/normal, 512 ORM; <=12MB.
Views: hero front-left, ortho side, clay, six paints, 30-degree hinge test. Revision cap: 3.
\"\"\"
import bpy, math, os, sys, json, urllib.request, subprocess, struct, time
import numpy as np
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
EVID=ROOT/'evidence'; EVID.mkdir(exist_ok=True)
START=time.time()
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene
sc.unit_settings.system='METRIC';sc.unit_settings.scale_length=1
sc.render.engine='CYCLES';sc.cycles.device='GPU';sc.cycles.samples=32
prefs=bpy.context.preferences.addons['cycles'].preferences
for backend in ['OPTIX','CUDA']:
 try:
  prefs.compute_device_type=backend;prefs.get_devices()
  if any(d.type==backend for d in prefs.devices):
   for d in prefs.devices:d.use=d.type==backend
   break
 except Exception:pass
else:raise RuntimeError('CUDA/OptiX GPU required')
sc.cycles.use_denoising=True;sc.render.image_settings.file_format='PNG'
sc.view_settings.view_transform='AgX'
sc.render.resolution_percentage=100
sc.render.film_transparent=False
GROUPS={'Hull':[],'Canopy':[],'Radiator_L':[],'Radiator_R':[]}
MATS={}; BAKE_LOG=[]

def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def uv_project(o):
 uv=o.data.uv_layers.new(name='UVMap') if not o.data.uv_layers else o.data.uv_layers.active
 for f in o.data.polygons:
  n=f.normal;axis=max(range(3),key=lambda i:abs(n[i]));a,b=([1,2] if axis==0 else [0,2] if axis==1 else [0,1])
  for li in f.loop_indices:
   p=o.data.vertices[o.data.loops[li].vertex_index].co
   uv.data[li].uv=(p[a]*.57,p[b]*.57)

def finish(o,name,mat,group='Hull',bevel=0,smooth=False):
 o.name=name
 if mat:o.data.materials.append(MATS[mat] if isinstance(mat,str) else mat)
 if bevel:
  active(o);m=o.modifiers.new('Machined edge radii','BEVEL');m.width=bevel;m.segments=3
  bpy.ops.object.modifier_apply(modifier=m.name)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 else:
  # Weighted face normals preserve armour planes while letting chamfers catch highlights.
  active(o)
  if bevel:
   for p in o.data.polygons:p.use_smooth=True
   m=o.modifiers.new('Weighted normals','WEIGHTED_NORMAL');m.keep_sharp=True;m.weight=50
   bpy.ops.object.modifier_apply(modifier=m.name)
 uv_project(o)
 if group:GROUPS[group].append(o)
 return o

def mesh(name,verts,faces,mat,group='Hull',bevel=0,smooth=False):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 o=bpy.data.objects.new(name,me);sc.collection.objects.link(o)
 # Make connected surfaces outward-facing.
 import bmesh
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 return finish(o,name,mat,group,bevel,smooth)

def box(name,loc,size,mat='Paint',bevel=.025,group='Hull'):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object
 o.data.transform(Matrix.Diagonal(Vector((*size,1))));return finish(o,name,mat,group,bevel)

def sphere(name,loc,scale,mat='Alloy',group='Hull',segs=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=segs,ring_count=rings,radius=1,location=loc);o=bpy.context.object
 o.data.transform(Matrix.Diagonal(Vector((*scale,1))));return finish(o,name,mat,group,0,True)

def tube(name,a,b,r,mat='Alloy',vertices=12,group='Hull',bevel=.008):
 a,b=Vector(a),Vector(b);d=b-a
 bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=r,depth=d.length,location=(a+b)/2)
 o=bpy.context.object;o.rotation_euler=d.to_track_quat('Z','Y').to_euler()
 o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
 return finish(o,name,mat,group,bevel,True)

def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
 bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
 o=bpy.context.object
 if axis=='Y':o.data.transform(Matrix.Rotation(pi/2,4,'X'))
 elif axis=='X':o.data.transform(Matrix.Rotation(pi/2,4,'Y'))
 return finish(o,name,mat,group,0,True)

def bolt(loc,axis='X',group='Hull',r=.026):
 p=Vector(loc);v=Vector((1,0,0) if axis=='X' else (0,1,0) if axis=='Y' else (0,0,1))
 return tube('Captive fastener',p-v*.012,p+v*.012,r,'Alloy',6,group,.003)

def lathe(name,profile,center,mat,group='Hull',segments=64,axis='Y'):
 # profile is axial-position, radius; can include outer and inner contour for real bell thickness
 vs=[]
 for y,r in profile:
  for j in range(segments):
   a=2*pi*j/segments;p=Vector((r*cos(a),y,r*sin(a)))
   if axis=='X':p=Vector((y,r*cos(a),r*sin(a)))
   vs.append(p+Vector(center))
 fs=[]
 for k in range(len(profile)-1):
  for j in range(segments):fs.append((k*segments+j,k*segments+(j+1)%segments,(k+1)*segments+(j+1)%segments,(k+1)*segments+j))
 return mesh(name,vs,fs,mat,group,0,True)

def download(asset,mapname):
 p=TEX/f'{asset}_{mapname}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mapname}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
  try:p.write_bytes(urllib.request.urlopen(req,timeout=90).read())
  except Exception:subprocess.run(['curl','-L','--fail','-A','Mozilla/5.0',url,'-o',str(p)],check=True)
 return p

# The two CC0 source sets are shared by every opaque surface. No image-generation sources.
PH={}
for asset in ['metal_plate','metal_plate_02']:
 for k in ['diff','nor_gl','arm']:
  im=bpy.data.images.load(str(download(asset,k)),check_existing=True)
  if k"'!='"'diff':im.colorspace_settings.name='Non-Color'
  PH[asset,k]=im

def bake_material(name,color,metal,rough,asset='metal_plate',relief=.1,neutral=False):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();N=nt.nodes;L=nt.links
 out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=N.new('ShaderNodeTexCoord')
 def tex(k):
  n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
 diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
 ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
 low=.69 if neutral else .62;high=.97 if neutral else 1.15
 ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
 ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
 L.new(bw.outputs[0],ramp.inputs[0])
 noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
 pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
 L.new(noise.outputs['Fac'],pits.inputs[0]);mix=N.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.23
 L.new(ramp.outputs[0],mix.inputs[1]);L.new(pits.outputs[0],mix.inputs[2]);L.new(mix.outputs[0],bs.inputs['Base Color'])
 sep=N.new('ShaderNodeSeparateColor');L.new(arm.outputs['Color'],sep.inputs[0])
 rm=N.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=max(.15,rough-.10);rm.inputs['To Max'].default_value=min(.85,rough+.15);L.new(sep.outputs['Green'],rm.inputs[0]);L.new(rm.outputs[0],bs.inputs['Roughness'])
 bs.inputs['Metallic'].default_value=metal
 nm=N.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=relief;L.new(normal.outputs['Color'],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,0));o=bpy.context.object;o.name='Tile bake fixture';o.data.materials.append(m)
 target=N.new('ShaderNodeTexImage');N.active=target
 emit=N.new('ShaderNodeEmission');combine=N.new('ShaderNodeCombineColor');combine.inputs['Red'].default_value=1;L.new(rm.outputs[0],combine.inputs['Green']);combine.inputs['Blue'].default_value=metal
 baked={}
 for kind,res in [('Base',1024),('Normal',1024),('ORM',512)]:
  im=bpy.data.images.new(f'{name}_{kind}',res,res,alpha=False)
  if kind"'!='"'Base':im.colorspace_settings.name='Non-Color'
  target.image=im;N.active=target
  if kind=='Normal':L.new(bs.outputs[0],out.inputs['Surface']);typ='NORMAL'
  else:
   L.new(mix.outputs[0] if kind=='Base' else combine.outputs[0],emit.inputs['Color']);L.new(emit.outputs[0],out.inputs['Surface']);typ='EMIT'
  active(o);sc.cycles.samples=8;sc.render.bake.margin=8;bpy.ops.object.bake(type=typ)
  im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();baked[kind]=im
 bpy.data.objects.remove(o,do_unlink=True)
 nt.nodes.clear();out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs[0],out.inputs[0]);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 for kind in ['Base','Normal','ORM']:
  n=N.new('ShaderNodeTexImage');n.image=baked[kind];n.label='Cycles baked shared tile'
  if kind=='Base':
   if name=='Paint':
    tint=N.new('ShaderNodeMixRGB');tint.name='Faction paint factor';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;tint.inputs[2].default_value=(.55,.56,.55,1);L.new(n.outputs[0],tint.inputs[1]);L.new(tint.outputs[0],bs.inputs['Base Color'])
   else:L.new(n.outputs[0],bs.inputs['Base Color'])
  elif kind=='Normal':
   nm=N.new('ShaderNodeNormalMap');L.new(n.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   # glTF occlusion channel, packed with roughness and metalness.
   ng=bpy.data.node_groups.get('glTF Material Output')
   if not ng:
    ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   gn=N.new('ShaderNodeGroup');gn.node_tree=ng;L.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
 print('BAKED',name,flush=True)

for args in [('Paint',(1,1,1),.63,.37,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)

def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
 m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
 if emission:bs.inputs['Emission Color'].default_value=(*color,1);bs.inputs['Emission Strength'].default_value=emission
 if alpha<1:
  m.surface_render_method='BLENDED';m.use_transparent_shadow=True
 MATS[name]=m;return m
special('Glass',(.19,.30,.36),.18,.14,.30)
special('Engines',(.12,.28,.65),.45,.26,1,1.8)
special('Lamps',(.21,.64,.72),.25,.25,1,2)

# Main pressure/armour shell; longitudinal stations define the silhouette.
SECTIONS=[(-5.85,.46,-.48,.40),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
def ring(y,w,bot,top):
 return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
vs=[]
for s in SECTIONS:vs+=ring(*s)
fs=[]
for k in range(len(SECTIONS)-1):
 for j in range(8):
  if j==0 and 1<=k<=4:continue # actual cockpit aperture, no opaque deck behind glazing
  fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
fs += [tuple(reversed(range(8))),tuple(range(56,64))]
mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.065)
# Low armoured dorsal spine behind the cockpit.
vs=[]
for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
fs=[tuple(reversed(range(8))),tuple(range(24,32))]
for k in range(3):
 for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
mesh('Dorsal reactor shielding',vs,fs,'Paint',bevel=.065)
# Recessed vent bays and fitted armour tiles along the shoulder.
for sign in [-1,1]:
 for k,(ya,yb) in enumerate([(-2.5,-1.0),(-.95,.65),(.70,2.1),(2.15,3.65),(3.70,4.35)]):
  x=sign*(2.13 if ya>=-.95 else 1.78)
  box('Inset thermal service bay',(x,(ya+yb)/2,.21),(.14,yb-ya,.78),'Cockpit',.06)
  for z in [-.25,.69]:tube('Service bay rim',(x*1.012,ya,z),(x*1.012,yb,z),.045,'Paint')
  for y in [ya,yb]:tube('Service bay rim',(x*1.012,y,-.25),(x*1.012,y,.69),.045,'Paint')
  for j in range(9):
   y=ya+.11+(yb-ya-.22)*j/8
   box('Heat exchanger louvre',(x*1.025,y,.21),(.09,.055,.65),'Alloy',.012)
  for y in [ya+.09,yb-.09]:
   for z in [-.19,.62]:bolt((x*1.04,y,z))
 # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
 for y in [.15,1.25,2.5,3.65]:
  x=sign*1.39
  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
  for xx in [-.17,.17]:
   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
 # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
 vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
 ff=[(0,1,2,3,4,5),(6,11,10,9,8,7)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
 mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.045)
 # Sensor blister: seat is tangent to nose flank, optical aperture points forward.
 sphere('Protected sensor blister',(sign*.83,-4.42,.20),(.22,.52,.17),'Alloy',segs=24,rings=12)
 tube('Sensor optical hood',(sign*.83,-4.88,.20),(sign*.83,-4.98,.20),.085,'Cockpit',24)
 tube('Sensor glass',(sign*.83,-4.985,.20),(sign*.83,-4.993,.20),.061,'Glass',24,bevel=.002)
# Panel seams with paired armoured ribs, belly doors closed.
for y,w,bot,top in SECTIONS[2:-1]:
 for sign in [-1,1]:
  tube('Armour seam', (sign*w*.69,y,bot+.04),(sign*w*.99,y,bot+.28),.015,'Alloy',8)
  for z in [bot+.35,top-.36]:bolt((sign*(w+.006),y,z),r=.024)
for y in [-1.8,.2,2.2]:
 box('Closed ventral equipment door',(0,y,-1.045),(1.3,1.8,.07),'Alloy',.07)
 box('Flush ventral armour',(0,y,-1.09),(1.19,1.69,.04),'Paint',.06)
 for x in [-.48,.48]:
  for yy in [-.66,.66]:bolt((x,y+yy,-1.12),'Z')

# Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
for sx in [-1,1]:
 for sz in [-1,1]:
  x=sx*1.46;z=1.39 if sz==1 else -1.06
  sphere('Domed propellant vessel',(x,2.40,z),(.49,2.03,.49),'Alloy',segs=48,rings=24)
  # A lathed capsule gives a substantial straight cylinder, not an ellipsoid tank.
  # Overlaid central jacket is flush with domed vessel.
  lathe('Tank cylinder',[(1.13,.46),(1.28,.49),(3.52,.49),(3.68,.46)],(x,0,z),'Alloy',segments=48)
  for y in [1.28,3.52]:
   torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
   torus('Retaining band lip',(x,y-.04,z),.499,.012,'Alloy',segs=48)
   box('Tank saddle',(x,y,z-sz*.40),(.72,.18,.25),'Paint',.035)
   for dx in [-.24,.24]:bolt((x+dx,y,z+sz*.49),'Z',r=.023)
  tube('Propellant feed',(x,4.17,z),(sx*1.17,4.85,sz*.63),.065,'Heatshield',16)
  torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
# Dorsal antenna/sensor covers (no markings).
for y in [1.35,3.5]:
 sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)

# Cockpit tub open beneath the transparent hinged canopy.
box('Cockpit floor',(0,-1.85,.73),(1.46,4.4,.14),'Cockpit',.06)
for y in [-2.70,-.92]:
 box('Seat pedestal',(0,y,.98),(.48,.53,.42),'Alloy',.06)
 box('Crashworthy seat cushion',(0,y,1.23),(.59,.69,.19),'Cockpit',.09)
 back=box('Seat back',(0,y+.29,1.67),(.59,.18,.81),'Cockpit',.085);back.rotation_euler.x=-.13
 box('Head restraint',(0,y+.32,2.12),(.40,.22,.27),'Cockpit',.08)
 for s in [-1,1]:
  tube('Seat frame',(s*.32,y+.30,1.12),(s*.32,y+.34,2.05),.035,'Alloy')
  tube('Harness webbing',(s*.14,y+.175,1.35),(s*.20,y+.16,2.00),.027,'Heatshield',8)
  box('Side console',(s*.55,y-.12,1.37),(.24,.80,.25),'Cockpit',.05)
  for j in range(5):box('Console switch',(s*.55,y-.39+j*.12,1.505),(.04,.04,.025),'Alloy',.005)
 tube('Control stick',(0,y-.27,1.27),(0,y-.40,1.55),.032,'Alloy')
 sphere('Hand grip',(0,y-.40,1.56),(.055,.06,.09),'Cockpit',segs=12,rings=8)
 # Instrument panel face sits ahead of each operator.
 box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
 for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
# Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
CAN=[(-4.55,.39,1.03,1.18),(-3.55,.66,1.09,1.88),(-2.25,.77,1.14,2.31),(-.72,.79,1.17,2.58),(.72,.77,1.20,2.56)]
# each station left sill, left roof, right roof, right sill
cr=[]
for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
for k in range(len(cr)-1):
 for j in range(3):
  v=[cr[k][j],cr[k][j+1],cr[k+1][j+1],cr[k+1][j]]
  o=mesh('Armoured glazing facet',v,[(0,1,2,3)],'Glass','Canopy')
  active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
for j in range(4):
 for k in range(4):
  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
for k in range(5):
 for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.048,'Alloy',12,'Canopy')
 for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
for s in [-1,1]:
 tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
 tube('Canopy actuator body',(s*.79,.65,1.23),(s*.82,-.30,1.16),.055,'Heatshield')
 tube('Canopy actuator rod',(s*.82,-.30,1.16),(s*.77,-.83,1.17),.029,'Alloy')

# Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
for y,r in [(4.56,1.01),(4.91,.94),(5.40,.57)]:torus('Reactor flange',(0,y,0),r,.065,'Alloy',segs=72)
for j in range(12):
 a=2*pi*j/12;d=Vector((cos(a),0,sin(a)))
 tube('Thrust frame strut',d*1.24+Vector((0,4.30,0)),d*.82+Vector((0,5.34,0)),.07,'Alloy',12)
 for y,r in [(4.55,1.12),(4.83,1.03)]:
  box('Magnet actuator',(r*cos(a),y,r*sin(a)),(.15,.27,.15),'Alloy',.02)
profile=[(5.45,.53),(5.65,.59),(5.95,.76),(6.3,.98),(6.7,1.21),(7.12,1.39),(7.56,1.52),(7.94,1.59),(8.0,1.59),(8.0,1.52),(7.56,1.45),(7.12,1.32),(6.7,1.14),(6.3,.91),(5.95,.69),(5.65,.52),(5.45,.46)]
lathe('Regeneratively cooled fusion bell',profile,(0,0,0),'Heatshield',segments=96)
for y,r in [(5.68,.61),(6.32,1.0),(7.13,1.41),(7.94,1.59)]:torus('Bell reinforcing hoop',(0,y,0),r,.036,'Alloy',segs=96)
for j in range(40):
 a=j*2*pi/40
 for (ya,ra),(yb,rb) in zip(profile[:8],profile[1:8]):
  tube('Bell cooling channel',((ra+.012)*cos(a),ya,(ra+.012)*sin(a)),((rb+.012)*cos(a),yb,(rb+.012)*sin(a)),.018,'Alloy',6,bevel=0)
for y,r in [(5.48,.56),(5.63,.59)]:torus('Copper coolant manifold',(0,y,0),r,.072,'Heatshield',segs=72)
lathe('Luminous fusion throat',[(5.46,.45),(5.47,.34),(5.48,.05)],(0,0,0),'Engines',segments=64)
torus('Modest ion sheath',(0,7.91,0),1.505,.023,'Engines',segs=96)
# Four RCS quads, four nozzles per block, all anchored near the rear hardpoints.
RCS=[]
for sx in [-1,1]:
 for sz in [-1,1]:
  c=Vector((sx*1.66,4.29,sz*1.02))
  sphere('RCS titanium pod',c,(.39,.48,.34),'Alloy',segs=24,rings=12)
  for ox,oz in [(-.13,-.11),(.13,-.11),(-.13,.11),(.13,.11)]:
   p=c+Vector((ox,.28,oz))
   lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],p,'Heatshield',segments=20)
   torus('RCS lip',p+Vector((0,.235,0)),.113,.013,'Alloy',segs=20)
   RCS.append(p+Vector((0,.24,0)))
# Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
for s in [-1,1]:
 box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
 for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
 tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
box('Muzzle collar',(0,-7.94,-.64),(.39,.12,.32),'Alloy',.025)
box('Muzzle bore',(0,-8.005,-.64),(.145,.012,.115),'Cockpit',.015)
# Deployed folding radiators: five replaceable carbon emitter cassettes per side.
for s,group in [(1,'Radiator_L'),(-1,'Radiator_R')]:
 # Transform from panel coordinates (u along deployment span, v fore/aft).
 def P(u,v,h=0):return Vector((s*(2.16+u*.86),v,.26-u*.55+h))
 for y in [-.05,.95,2.0,3.05,4.10]:
  tube('Radiator hinge axle',(s*2.15,y-.13,.26),(s*2.15,y+.13,.26),.09,'Alloy',20)
  tube('Radiator deployment arm',(s*2.04,y,-.27),P(.62,y,-.04),.045,'Heatshield',12,group)
 for i in range(5):
  ya=-.12+i*.85;yb=ya+.81
  vv=[P(u,v,h) for h in [-.045,.045] for u,v in [(0,ya),(1.267,ya),(1.267,yb),(0,yb)]]
  mesh('Radiator cassette',vv,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Radiator',group,.014)
  for u in [0,1.267]:tube('Radiator cassette edging',P(u,ya,.05),P(u,yb,.05),.028,'Alloy',8,group)
  for y in [ya,yb]:tube('Radiator collector',P(0,y,.05),P(1.267,y,.05),.026,'Alloy',8,group)
  for j in range(16):
   v=ya+.06+j*.046
   tube('Radiator capillary',P(.06,v,.052),P(1.20,v,.052),.011,'Heatshield',6,group,0)
  for u in [.04,1.225]:
   for y in [ya+.04,yb-.04]:sphere('Radiator captive screw',P(u,y,.08),(.019,.019,.019),'Alloy',group,segs=8,rings=4)
# Hull-mounted lights and hardpoint shoes.
for s in [-1,1]:
 box('Nav light housing',(s*2.18,3.72,.66),(.18,.34,.16),'Alloy',.04)
 sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
 box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)

# Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
# never apply transforms to these moving nodes.
PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
OBJECTS={}
for name,objs in GROUPS.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in objs:o.select_set(True)
 bpy.context.view_layer.objects.active=objs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 pivot=Vector(PIVOTS[name]);world=o.matrix_world.copy()
 # Bake each joined mesh's world transform into vertices, preserving the authored moving origin.
 o.data.transform(Matrix.Translation(-pivot)@world);o.matrix_world=Matrix.Translation(pivot)
 # Remove zero-area faces before export.
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-10];bmesh.ops.delete(bm,geom=dead,context='FACES');bm.to_mesh(o.data);bm.free()
 OBJECTS[name]=o
for name in ['Canopy','Radiator_L','Radiator_R']:OBJECTS[name].parent=OBJECTS['Hull']
# Helpers: empty sockets are exported, display objects are not.
def socket(name,p,parent='Hull'):
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
socket('Thruster_1',(0,8,0))
for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
socket('Muzzle_1',(0,-8.02,-.64))
for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
socket('Seat_Pilot',(0,-2.70,1.29));socket('Seat_Copilot',(0,-.92,1.29));socket('Camera_Chase',(0,12,5.3))
socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
# Exact overall dimensions: apply a single coordinate correction to data and node locations,
# not object transforms, preserving all hinge frames.
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(v) for o in OBJECTS.values() for v in o.bound_box]
lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
S=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)))
SM=Matrix.Diagonal(Vector((*S,1)))
for o in list(sc.objects):
 if o.type=='MESH':o.data.transform(SM)
 o.location=Vector([o.location[i]*S[i] for i in range(3)])
bpy.context.view_layer.update()
for o in OBJECTS.values():
 active(o);tri=o.modifiers.new('Export triangulation','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
if triangles>90000:
 for o in OBJECTS.values():
  active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
 triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
print('GEOMETRY',triangles,'triangles',flush=True)

# Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.
world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world
nt=world.node_tree;nt.nodes.clear();N=nt.nodes;L=nt.links
out=N.new('ShaderNodeOutputWorld');bg=N.new('ShaderNodeBackground');bg.inputs['Strength'].default_value=.24
coord=N.new('ShaderNodeTexCoord');sep=N.new('ShaderNodeSeparateXYZ');L.new(coord.outputs['Normal'],sep.inputs[0]);r=N.new('ShaderNodeValToRGB');r.color_ramp.elements[0].position=0;r.color_ramp.elements[0].color=(.003,.006,.014,1);r.color_ramp.elements[1].position=1;r.color_ramp.elements[1].color=(.025,.043,.075,1);L.new(sep.outputs['Z'],r.inputs[0]);L.new(r.outputs[0],bg.inputs[0]);L.new(bg.outputs[0],out.inputs[0])
def area(name,loc,power,color,size,target=(0,0,0)):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.color=color;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler()
area('Upper front white key',(3,-8,12),2500,(1,.97,.92),7)
area('Cool soft fill',(-8,-1,6),1800,(.64,.78,1),8)
area('Engine rim',(1,9,8),2900,(.72,.83,1),6)
area('Soft belly fill',(3,-2,-6),700,(.66,.73,1),6)
sun=bpy.data.lights.new('Sunlight','SUN');sun.energy=1.7;sun.angle=.08;sun.color=(1,.98,.95);so=bpy.data.objects.new('Sunlight',sun);sc.collection.objects.link(so);so.rotation_euler=(.4,-.5,-.4)
camd=bpy.data.cameras.new('Evidence camera');cam=bpy.data.objects.new('Evidence camera',camd);sc.collection.objects.link(cam);sc.camera=cam
HERO=(15,-22,14)
def camera(loc,target=(0,0,.1),ortho=19.0):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=ortho
camera(HERO)
def render(path,w=1920,h=1080,samples=32):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.cycles.samples=samples;sc.render.filepath=str(path);bpy.ops.render.render(write_still=True)
# First a clay gate with all silhouette geometry visible.
clay=special('_Clay',(.32,.34,.36),.15,.42)
sc.view_layers[0].material_override=clay
render(ROOT/'clay.png',1920,1080,24)
sc.view_layers[0].material_override=None
# Save and export the first complete asset before final evidence renders.
paint=MATS['Paint'];pnt=paint.node_tree;bs=pnt.nodes.get('Principled BSDF');tint=pnt.nodes['Faction paint factor'];base=next(n for n in pnt.nodes if n.type=='TEX_IMAGE' and n.image.name=='Paint_Base')
pnt.links.new(base.outputs[0],bs.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in sc.objects:
 if o.type in ['MESH','EMPTY']:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'interceptor-hardsf.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
# Make the runtime paint and glass contracts explicit in the GLB JSON.
p=ROOT/'interceptor-hardsf.glb';raw=p.read_bytes();jl,jt=struct.unpack_from('<II',raw,12);g=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
for m in g['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.55,.56,.55,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
j=json.dumps(g,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
p.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4E4F534A)+j+binary)
pnt.links.new(tint.outputs[0],bs.inputs['Base Color'])
# Manifest uses glTF coordinates, not Blender coordinates.
def gltfpos(v):return [round(v.x,5),round(v.z,5),round(-v.y,5)]
pts=[o.matrix_world@Vector(v) for o in OBJECTS.values() for v in o.bound_box]
lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
PAINTS=[('Naval grey',[.55,.56,.55]),('Gunmetal',[.16,.185,.21]),('Olive drab',[.22,.27,.16]),('Desert sand',[.64,.52,.34]),('Arctic white',[.86,.88,.87]),('Deep navy',[.055,.105,.19])]
manifest={'asset':'interceptor-hardsf','units':'metres','forward':'+Z','up':'+Y','left':'+X','origin':'hull centre of mass estimate','nodes':{o.name:{'position':gltfpos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'socket'} for o in sc.objects if o.type in ['MESH','EMPTY']},'axes':{'Canopy':[1,0,0],'Radiator_L':[0,0,1],'Radiator_R':[0,0,1]},'bounding_box':{'min':[round(lo.x,5),round(lo.z,5),round(-hi.y,5)],'max':[round(hi.x,5),round(hi.z,5),round(-lo.y,5)],'size':[6.5,4.2,16.0]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'triangles':triangles,'materials':list(MATS)[:-1],'bakes':BAKE_LOG,'moving_part_test':{'degrees':30,'origin_drift_metres':0}}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('\\n'.join(['Original 16.0 × 6.5 × 4.2 m hard-SF tandem interceptor; hinged canopy, deployed radiators, railgun, four RCS quads and fusion bell.',f'{triangles:,} triangles; fixed Hull and three moving mesh nodes; Y up, +Z nose; metre scale.', '8 materials: Paint, Alloy, Heatshield, Radiator, Cockpit, Glass, Engines, Lamps.', 'Shared Cycles-baked tiling colour/normal 1024 px, packed ORM 512 px; Paint neutral texture × linear faction factor.', 'CC0 photographic sources: Poly Haven metal_plate and metal_plate_02; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/metal_plate_02 .','Reference differences: radiator cassettes are deployed; original cheek armour, twin-rail muzzle and engine framing; no exhaust plume or markings.'])+'\\n')
(EVID/'bake_provenance.json').write_text(json.dumps(BAKE_LOG,indent=2))
bpy.ops.wm.save_as_mainfile(filepath=str(EVID/'interceptor.blend'))
print('FIRST_GLB_COMPLETE',round(time.time()-START,1),p.stat().st_size,flush=True)
render(ROOT/'final.png')
camera((20,0,3.0),(0,0,.25),18.5);render(ROOT/'side.png',1920,1080,24)
camera((12,19,10),(0,1,0),18.0);render(EVID/'rear.png',1280,800,24)
camera((7,-10,6),(0,-1.7,1.1),7.7);render(EVID/'cockpit.png',1280,900,24)
# Explicit 30 degree test of every moving assembly around its own pivot, then restore.
camera(HERO)
for n in ['Canopy','Radiator_L','Radiator_R']:
 o=OBJECTS[n];before=o.matrix_world.translation.copy();o.rotation_euler[0 if n=='Canopy' else 1]=math.radians(30)
 bpy.context.view_layer.update();assert (o.matrix_world.translation-before).length<1e-6
render(EVID/'pivot_test.png',1280,720,24)
for n in ['Canopy','Radiator_L','Radiator_R']:OBJECTS[n].rotation_euler=(0,0,0)
# Six-paint proof in a real rendered 3x2 contact sheet, no typography on the asset.
frames=[]
for name,c in PAINTS:
 tint.inputs[2].default_value=(*c,1);render(EVID/f'paint_{name.replace(\" \",\"_\")}.png',640,360,16)
 im=bpy.data.images.load(str(EVID/f'paint_{name.replace(\" \",\"_\")}.png'),check_existing=False);a=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(a);frames.append(a.reshape(360,640,4).copy());bpy.data.images.remove(im)
grid=np.concatenate([np.concatenate(frames[3:],axis=1),np.concatenate(frames[:3],axis=1)],axis=0)
im=bpy.data.images.new('Six faction paints',1920,720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
tint.inputs[2].default_value=(.55,.56,.55,1)
print('BUILD_COMPLETE',round(time.time()-START,1),triangles,p.stat().st_size,flush=True)
PY
python3 -m py_compile build_interceptor-hardsf.py
scp build_interceptor-hardsf.py gex44:/root/fal-author/interceptor-hardsf-v1/
ssh gex44 '/root/blender-spike/blender-5.2.0-linux-x64/blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py' > /tmp/interceptor-build.log 2>&1" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'tail -12 /tmp/interceptor-build.log
date -u +%FT%TZ' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
[INFO ] services.locationservice ...............: Not adding link to MakeHuman user data, since neither explicit path nor auto-discovery is set in preferences.
[INFO ] mpfb.init ..............................: Build info FROM_SOURCE
[INFO ] mpfb.init ..............................: Online access preference is not enabled. Not checking if MakeHuman is online.
[INFO ] mpfb.init ..............................: MPFB initialization has finished.
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Paint
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Alloy
2026-10-16T18:46:01Z

codex
The first build is running on the GPU. The bake/export probe passed, and the shared hull and metal textures have baked successfully. I’ll inspect the clay render first, then the textured views and the 30-degree hinge test.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "tail -16 /tmp/interceptor-build.log
ssh gex44 'ls -lh /root/fal-author/interceptor-hardsf-v1/*.png /root/fal-author/interceptor-hardsf-v1/*.glb 2>/dev/null'
command -v forge-agent
command -v geminix
sed -n '1,100p' /home/claude-agent/bin/geminix" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 383ms:
Info: Baking map saved to internal image, save it externally or pack it
BAKED Alloy
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Heatshield
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:186: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
-rw-r--r-- 1 root root 1.9K Oct 16 20:41 /root/fal-author/interceptor-hardsf-v1/preflight.glb
/home/claude-agent/bin/forge-agent
/home/claude-agent/bin/geminix
#!/bin/sh
# geminix: one-shot / scriptable Gemini, Antigravity CLI harness (default since 14 Oct 2026):
#   geminix "prompt"   |   geminix < file.md      (prints the final answer as text)
# Old Claude Code lane: geminix-cc, or FORGE_GEMINI_HARNESS=claude-code.
[ "${FORGE_GEMINI_HARNESS:-antigravity}" = claude-code ] && exec "$HOME/bin/geminix-cc" "$@"
if [ $# -eq 0 ] && [ -t 0 ]; then
  echo "geminix: no prompt given, starting the interactive session" >&2
  exec "$HOME/bin/gemini"
fi
exec "$HOME/bin/agy4" --model forge:build-gemini-4 -p "$@"

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "tail -14 /tmp/interceptor-build.log
ssh gex44 'ls -lh /root/fal-author/interceptor-hardsf-v1/*.png /root/fal-author/interceptor-hardsf-v1/*.glb 2>/dev/null'
date -u +%FT%TZ" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 440ms:
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Heatshield
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:186: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
-rw-r--r-- 1 root root 1.9K Oct 16 20:41 /root/fal-author/interceptor-hardsf-v1/preflight.glb
2026-10-16T18:46:40Z

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "node --version
python3 /home/claude-agent/.agents/skills/blender-game-assets/scripts/audit_glb.py --help
ls /tmp/node_modules/three/package.json /tmp/node_modules/playwright/package.json /home/claude-agent/node_modules/playwright/package.json 2>/dev/null
rg --files /home/claude-agent/.agents/skills/blender-game-assets | rg 'audit|render|runtime'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 197ms:
v20.20.2
usage: audit_glb.py [-h] [--strict] [--rig]
                    [--require-sockets REQUIRE_SOCKETS]
                    glb

Read-only audit of uncompressed GLB triangle meshes; requires numpy. Report
mode exits 0 even with findings. --strict exits 1 for findings. Invalid or
unsupported input exits 2. This is not a complete glTF validator.

positional arguments:
  glb

options:
  -h, --help            show this help message and exit
  --strict
  --rig                 print the skin/joint report (always computed; this
                        only keeps it in the output)
  --require-sockets REQUIRE_SOCKETS
                        comma-separated node names that must exist (e.g.
                        target,ground,vfx-hit,vfx-death)
/home/claude-agent/.agents/skills/blender-game-assets/reference/runtime-parity-and-diagnostics.md
/home/claude-agent/.agents/skills/blender-game-assets/scripts/audit_glb.py
/home/claude-agent/.agents/skills/blender-game-assets/scripts/__pycache__/audit_glb.cpython-312.pyc

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'mkdir -p /tmp/interceptor-runtime
npm install --prefix /tmp/interceptor-runtime three playwright > /tmp/interceptor-runtime-install.log 2>&1
cat /tmp/interceptor-runtime-install.log
ls -d /home/claude-agent/.cache/ms-playwright/chromium* 2>/dev/null' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'ps -eo pid,etime,%cpu,%mem,args | grep \"blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build\" | head -3'
tail -5 /tmp/interceptor-build.log" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 566ms:
3831453       00:00  0.0  0.0 bash -c ps -eo pid,etime,%cpu,%mem,args | grep "blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build" | head -3
3831455       00:00  0.0  0.0 grep blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:186: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
GEOMETRY 87994 triangles
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:420: DeprecationWarning: 'World.use_nodes' is expected to be removed in Blender 6.0
  world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world

 succeeded in 5049ms:

added 3 packages in 5s
/home/claude-agent/.cache/ms-playwright/chromium-1161
/home/claude-agent/.cache/ms-playwright/chromium-1223
/home/claude-agent/.cache/ms-playwright/chromium_headless_shell-1161
/home/claude-agent/.cache/ms-playwright/chromium_headless_shell-1223

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'tail -25 /tmp/interceptor-build.log' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
GEOMETRY 87994 triangles
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:420: DeprecationWarning: 'World.use_nodes' is expected to be removed in Blender 6.0
  world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world
01:03.607  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/clay.png'
INFO Draco is available, use library at /root/blender-spike/blender-5.2.0-linux-x64/5.2/scripts/addons_core/io_scene_gltf2/libbf_intern_draco_bridge.so
INFO MeshOptimizer is available, use library at /root/blender-spike/blender-5.2.0-linux-x64/5.2/scripts/addons_core/io_scene_gltf2/libbf_intern_meshopt_bridge.so
20:46:59 | INFO: Starting glTF 2.0 export
20:46:59 | INFO: Extracting primitive: Continuous pressure hull
20:46:59 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
20:46:59 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
20:46:59 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
20:46:59 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
20:46:59 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
20:46:59 | INFO: Primitives created: 8
20:46:59 | INFO: Extracting primitive: Armoured glazing facet
20:46:59 | INFO: Primitives created: 4
20:46:59 | INFO: Extracting primitive: Cylinder.546
20:46:59 | INFO: Primitives created: 3
20:46:59 | INFO: Extracting primitive: Cylinder.656
20:46:59 | INFO: Primitives created: 3
20:46:59 | INFO: Finished glTF 2.0 export in 0.19168305397033691 s

Info: Saved as "interceptor.blend"
FIRST_GLB_COMPLETE 63.4 7113348

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/clay.png /tmp/interceptor-clay-r1.png
scp gex44:/root/fal-author/interceptor-hardsf-v1/interceptor-hardsf.glb /tmp/interceptor-runtime/interceptor-hardsf.glb
ls -lh /tmp/interceptor-clay-r1.png' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 2310ms:
-rw-r--r-- 1 claude-agent claude-agent 2.3M Oct 16 18:47 /tmp/interceptor-clay-r1.png

hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'tail -10 /tmp/interceptor-build.log
scp gex44:/root/fal-author/interceptor-hardsf-v1/final.png /tmp/interceptor-final-r1.png
scp gex44:/root/fal-author/interceptor-hardsf-v1/side.png /tmp/interceptor-side-r1.png
python3 /home/claude-agent/.agents/skills/blender-game-assets/scripts/audit_glb.py /tmp/interceptor-runtime/interceptor-hardsf.glb --strict > /tmp/interceptor-audit-r1.json
head -45 /tmp/interceptor-audit-r1.json' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 102930ms:
 succeeded in 2293ms:
01:10.754  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/final.png'
01:18.357  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/side.png'
01:22.544  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/rear.png'
01:28.105  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/cockpit.png'
01:32.241  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/pivot_test.png'
01:33.898  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Naval_grey.png'
01:35.600  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Gunmetal.png'
01:37.052  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Olive_drab.png'
01:38.465  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Desert_sand.png'
01:39.764  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Arctic_white.png'
{
  "file": "interceptor-hardsf.glb",
  "sha256": "ab158c3f4b70cb3f69c24d18ef77fc693d6c3e99b21b5b9784766831aea1e24a",
  "bytes": 7113348,
  "primitive_count": 18,
  "material_count": 8,
  "embedded_images": 15,
  "external_uris": [],
  "primitives": [
    {
      "mesh": "Armoured glazing facet",
      "primitive": 0,
      "triangles": 144,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Armoured glazing facet",
      "primitive": 1,
      "triangles": 2340,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Armoured glazing facet",
      "primitive": 2,
      "triangles": 2258,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Armoured glazing facet",
      "primitive": 3,
      "triangles": 2776,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Cylinder.546",
      "primitive": 0,
      "triangles": 2070,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {

hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse Completed
codex
The first complete GLB is 7.1 MB with about 88,000 triangles. The renders revealed a gap around the canopy base and tank end caps that need cleaner joins. I’m fixing those, lowering the canopy toward the reference profile, and adding fitted armour seams to the broad hull surfaces.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "timeout 240 geminix 'You are the independent visual critic for a Blender asset, findings only. Open and visually inspect these actual images: /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg (reference), /tmp/interceptor-final-r1.png, /tmp/interceptor-side-r1.png, /tmp/interceptor-clay-r1.png. Task: realistic hard-SF military space interceptor, no wings, 16m long, tandem canopy, fusion bell, 4 RCS quads, deployed side radiators, tanks, railgun. Deployed radiator pose is deliberately different from reference. Author already identified cockpit base gaps, tank cap joins, canopy too tall and uniform hull needing fitted armour seams. Return at most five visible findings ordered by impact, with concrete corrections and acceptance conditions; PASS/REVISE/BLOCKED. You MUST inspect actual image files using image tools; if unable, return BLOCKED. Read-only: do not edit files or spawn agents. Time limit 3 minutes.' > /tmp/interceptor-critic-r1.txt 2>&1" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "cat > /tmp/interceptor-runtime/index.html <<'HTML'
<"'!DOCTYPE html><html><head><meta charset="utf-8"><style>body{margin:0;background:#050a11;}canvas{display:block}</style><script type="importmap">{"imports":{"three":"/node_modules/three/build/three.module.js","three/addons/":"/node_modules/three/examples/jsm/"}}</script></head><body><script type="module">
import * as THREE from '"'three';import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js';
const renderer=new THREE.WebGLRenderer({antialias:true,preserveDrawingBuffer:true});renderer.setSize(1600,900);renderer.setPixelRatio(1);renderer.toneMapping=THREE.AgXToneMapping;renderer.toneMappingExposure=1;document.body.appendChild(renderer.domElement);
const scene=new THREE.Scene();scene.background=new THREE.Color('#080e18');const env=new THREE.PMREMGenerator(renderer);scene.environment=env.fromScene(new RoomEnvironment(),.05).texture;
const camera=new THREE.OrthographicCamera(-9.5,9.5,9.5*9/16,-9.5*9/16,.1,100);camera.position.set(15,14,22);camera.lookAt(0,.1,0);
const key=new THREE.DirectionalLight(0xfff5eb,3);key.position.set(3,12,8);scene.add(key);const fill=new THREE.DirectionalLight(0xaacbff,1.4);fill.position.set(-8,6,-1);scene.add(fill);
const gltf=await new GLTFLoader().loadAsync('/interceptor-hardsf.glb');scene.add(gltf.scene);gltf.scene.updateMatrixWorld(true);const bounds=new THREE.Box3().setFromObject(gltf.scene);const materials=new Map();let meshes=0;gltf.scene.traverse(o=>{if(o.isMesh){meshes++;materials.set(o.material.name,o.material)}});
for(let i=0;i<4;i++)renderer.render(scene,camera);
window.result={bounds:{min:bounds.min.toArray(),max:bounds.max.toArray(),size:bounds.getSize(new THREE.Vector3()).toArray()},drawCalls:renderer.info.render.calls,triangles:renderer.info.render.triangles,meshes,materials:[...materials.keys()],webgl:renderer.getContext().getParameter(renderer.getContext().VERSION),paintFactor:materials.get('Paint').color.toArray()};
window.setPaint=(rgb)=>{materials.get('Paint').color.fromArray(rgb);renderer.render(scene,camera)};window.done=true;
</script></body></html>
HTML
cat > /tmp/interceptor-runtime/capture.mjs <<'JS'
import {chromium} from 'playwright';import fs from 'fs';
const browser=await chromium.launch({headless:true,executablePath:'/home/claude-agent/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',args:['--no-sandbox','--use-angle=swiftshader','--enable-unsafe-swiftshader']});const page=await browser.newPage({viewport:{width:1600,height:900}});let errors=[];page.on('pageerror',e=>errors.push(String(e)));await page.goto('http://127.0.0.1:8347/');await page.waitForFunction(()=>window.done,{timeout:60000});await page.screenshot({path:'/tmp/interceptor-runtime/runtime.png'});fs.writeFileSync('/tmp/interceptor-runtime/runtime.json',JSON.stringify({...await page.evaluate(()=>window.result),errors},null,2));await browser.close();
JS
python3 -m http.server 8347 --directory /tmp/interceptor-runtime > /tmp/interceptor-runtime/server.log 2>&1" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'node /tmp/interceptor-runtime/capture.mjs > /tmp/interceptor-runtime/capture.log 2>&1
cat /tmp/interceptor-runtime/capture.log
cat /tmp/interceptor-runtime/runtime.json' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 exited 1 in 577ms:
node:internal/modules/run_main:123
    triggerUncaughtException(
    ^

browserType.launch: Failed to launch chromium because executable doesn't exist at /home/claude-agent/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome
    at /tmp/interceptor-runtime/capture.mjs:2:30 {
  log: [],
  name: 'Error'
}

Node.js v20.20.2
cat: /tmp/interceptor-runtime/runtime.json: No such file or directory

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'tail -8 /tmp/interceptor-critic-r1.txt
tail -3 /tmp/interceptor-build.log
date -u +%FT%TZ' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:

2. **Open cavity in front of the windscreen and boxy, unintegrated canopy**
   - **What is visible**: In [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) and [interceptor-clay-r1.png](file:///tmp/interceptor-clay-r1.png), the canopy sits inside an open rectangular trough that leaves a gaping hole at the front of the nose and open gaps along the side sills. The canopy itself is a tall, flat-roofed greenhouse box instead of a low teardrop flush with the dorsal spine.
   - **Correction**: Close the forward nose deck so it meets the front windscreen frame seamlessly. Lower the canopy roof by ~35%, angle the side glazing inward, and fairs the rear canopy frame smoothly into the dorsal spine without a vertical step.
   - **Acceptance condition**: Zero visible gaps or open interior troughs around the canopy perimeter in 3/4 and clay views; canopy roofline flows continuously into the nose and spine.

3. **Missing recessed forward flank intakes (replaced by flat surface grilles)**
   - **What is visible**: In [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/BUILD_COMPLETE 100.8 87994 7113348

Blender quit
2026-10-16T18:48:49Z

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cat /tmp/interceptor-critic-r1.txt
rg --files /home/claude-agent/.cache/ms-playwright/chromium-1223 | head -8' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
**Verdict: REVISE**

### Top 5 Visible Findings (Ordered by Impact)

1. **Foreshortened hull proportions, blunt nose, and oversized engine bell flare**
   - **What is visible**: In [interceptor-side-r1.png](file:///tmp/interceptor-side-r1.png), the fuselage is stubby and tall (roughly 2.3:1 length-to-height vs the reference's sleek 3.8:1 ratio in [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg)). The forward nose terminates abruptly right in front of the windscreen in a flat vertical face, and the fusion nozzle flares out as a wide cone that is taller than the hull.
   - **Correction**: Extend and taper the forward nose section ahead of the canopy to match the 16.0m x 4.2m reference silhouette. Reduce the fusion nozzle expansion angle to a tight parabolic bell whose outer rim stays within the dorsal-to-ventral tank height envelope.
   - **Acceptance condition**: Side orthographic view matches the reference length-to-height ratio (3.8:1), with a long tapered nose cone and a nozzle diameter no taller than the outer tank profile.

2. **Open cavity in front of the windscreen and boxy, unintegrated canopy**
   - **What is visible**: In [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) and [interceptor-clay-r1.png](file:///tmp/interceptor-clay-r1.png), the canopy sits inside an open rectangular trough that leaves a gaping hole at the front of the nose and open gaps along the side sills. The canopy itself is a tall, flat-roofed greenhouse box instead of a low teardrop flush with the dorsal spine.
   - **Correction**: Close the forward nose deck so it meets the front windscreen frame seamlessly. Lower the canopy roof by ~35%, angle the side glazing inward, and fairs the rear canopy frame smoothly into the dorsal spine without a vertical step.
   - **Acceptance condition**: Zero visible gaps or open interior troughs around the canopy perimeter in 3/4 and clay views; canopy roofline flows continuously into the nose and spine.

3. **Missing recessed forward flank intakes (replaced by flat surface grilles)**
   - **What is visible**: In [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg), deep trapezoidal intakes are carved into the forward cheeks below the front cockpit, creating the main width step between the mid-hull and the nose. In [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) and [interceptor-side-r1.png](file:///tmp/interceptor-side-r1.png), the hull flanks are flat slabs with two surface-mounted louvered grilles, plus an exposed dark cutout behind the deployed radiator panel.
   - **Correction**: Remove the two pasted-on side grilles. Model the recessed forward cheek intakes into the hull geometry ahead of the radiator bay, and recess a clean housing pocket behind the deployed radiator so the hull wall does not look torn open.
   - **Acceptance condition**: 3/4 and side views show deep structural cheek intakes below the forward cockpit and a finished mechanical recess behind the deployed radiator.

4. **Bright silver hull shader, shading artifact on flank, and missing heat-scorched metal**
   - **What is visible**: In [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) and [interceptor-side-r1.png](file:///tmp/interceptor-side-r1.png), the hull reads as bright, glossy silver-wh/home/claude-agent/.cache/ms-playwright/chromium-1223/DEPENDENCIES_VALIDATED
/home/claude-agent/.cache/ms-playwright/chromium-1223/INSTALLATION_COMPLETE
/home/claude-agent/.cache/ms-playwright/chromium-1223/chrome-linux/resources.pak
/home/claude-agent/.cache/ms-playwright/chromium-1223/chrome-linux/chrome_crashpad_handler
/home/claude-agent/.cache/ms-playwright/chromium-1223/chrome-linux/libvk_swiftshader.so
/home/claude-agent/.cache/ms-playwright/chromium-1223/chrome-linux/libGLESv2.so
/home/claude-agent/.cache/ms-playwright/chromium-1223/chrome-linux/vk_swiftshader_icd.json
/home/claude-agent/.cache/ms-playwright/chromium-1223/chrome-linux/PrivacySandboxAttestationsPreloaded/privacy-sandbox-attestations.dat

 succeeded in 53061ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "cp build_interceptor-hardsf.py /tmp/interceptor-build-r1.py
python3 - <<'PY'
p='build_interceptor-hardsf.py'
s=open(p).read().replace(\"m.segments=3\",\"m.segments=2\")
s=s.replace(\"r,'Alloy',6,group,.003)\",\"r,'Alloy',6,group,0)\")
s=s.replace(\"('Paint',(1,1,1),.63,.37\", \"('Paint',(1,1,1),.42,.46\")
s=s.replace(\"low=.69 if neutral else .62;high=.97 if neutral else 1.15\", \"low=.58 if neutral else .62;high=.94 if neutral else 1.15\")
s=s.replace(\"mix.inputs[0].default_value=.23\", \"mix.inputs[0].default_value=.40\")
s=s.replace(\"(-5.85,.46,-.48,.40)\",\"(-6.10,.40,-.44,.24)\")
s=s.replace(\"mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.065)\",\"hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)\")
s=s.replace(\"for k,(ya,yb) in enumerate([(-2.5,-1.0),(-.95,.65),(.70,2.1),(2.15,3.65),(3.70,4.35)]):\", \"for k,(ya,yb) in enumerate([(-.05,.65),(.70,2.1),(2.15,3.65),(3.70,4.35)]):\")
s=s.replace(\"ff=[(0,1,2,3,4,5),(6,11,10,9,8,7)]+\", \"ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+\")
s=s.replace(\"mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.045)\", \"\"\"cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
 # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
 cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
 for target in [hull_shell,cheek]:
  active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
 bpy.data.objects.remove(cutter,do_unlink=True)
 box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
 for j in range(8):
  box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
 # Framed opening and surrounding armour remain continuous.
 for y in [-2.54,-1.20]:
  x=sign*(1.62 if y<-2 else 1.95)
  tube('Cheek aperture vertical rim',(x,y,.10),(x,y,.64),.032,'Alloy',10)
\"\"\")
s=s.replace(\"  sphere('Domed propellant vessel',(x,2.40,z),(.49,2.03,.49),'Alloy',segs=48,rings=24)\\n  # A lathed capsule gives a substantial straight cylinder, not an ellipsoid tank.\\n  # Overlaid central jacket is flush with domed vessel.\\n  lathe('Tank cylinder',[(1.13,.46),(1.28,.49),(3.52,.49),(3.68,.46)],(x,0,z),'Alloy',segments=48)\",\"\"\"  cap=[]
  for j in range(13):
   a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
  cap.append((3.52,.49))
  for j in range(1,13):
   a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
\"\"\")
s=s.replace(\"box('Cockpit floor',(0,-1.85,.73),(1.46,4.4,.14),'Cockpit',.06)\\nfor y in [-2.70,-.92]:\", \"cockpit_start=len(GROUPS['Hull'])\\nbox('Cockpit floor',(0,-1.55,.73),(1.46,3.9,.14),'Cockpit',.06)\\nfor y in [-2.25,-.65]:\")
s=s.replace(\"# Canopy mesh: faceted windscreen\", \"for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24\\n# Canopy mesh: faceted windscreen\")
s=s.replace(\"CAN=[(-4.55,.39,1.03,1.18),(-3.55,.66,1.09,1.88),(-2.25,.77,1.14,2.31),(-.72,.79,1.17,2.58),(.72,.77,1.20,2.56)]\", \"\"\"CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
# Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
def interp(y,rows):
 if y<=rows[0][0]:return rows[0][1:]
 if y>=rows[-1][0]:return rows[-1][1:]
 for a,b in zip(rows,rows[1:]):
  if a[0]<=y<=b[0]:
   t=(y-a[0])/(b[0]-a[0]);return tuple(a[j]*(1-t)+b[j]*t for j in range(1,len(a)))
yys=sorted(set([-5.35,-4.25,-2.9,-1.1,.70]+[r[0] for r in CAN]))
for sign in [-1,1]:
 for ya,yb in zip(yys,yys[1:]):
  vv=[]
  for y in [ya,yb]:
   w,bot,top=interp(y,SECTIONS);cw,cb,ct=interp(y,CAN)
   if y<CAN[0][0]:cw=0;cb=top+.012
   vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
  o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
  active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
 for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
\"\"\")
s=s.replace(\".048,'Alloy',12,'Canopy'\", \".052,'Paint',12,'Canopy'\")
s=s.replace(\"camera((20,0,3.0),(0,0,.25),18.5)\", \"camera((20,0,.25),(0,0,.25),18.5)\")
s=s.replace(\"socket('Seat_Pilot',(0,-2.70,1.29));socket('Seat_Copilot',(0,-.92,1.29))\", \"socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05))\")
# Nose and spine panel seams with shallow physical breaks and local edge catches.
mark=\"# Four long cylindrical propellant tanks\"
new=\"\"\"# Armour seams follow the longitudinal loft, held just proud of the skin (not z-fighting).
for sign in [-1,1]:
 for y in [-4.82,-3.55,-2.82,-.86,1.04,2.72,3.85]:
  # interpolate the outer side face at this longitudinal station
  for a,b in zip(SECTIONS,SECTIONS[1:]):
   if a[0]<=y<=b[0]:
    t=(y-a[0])/(b[0]-a[0]);w=a[1]*(1-t)+b[1]*t;bot=a[2]*(1-t)+b[2]*t;top=a[3]*(1-t)+b[3]*t;break
  x=sign*(w+.009)
  # narrow dark join with a fine metal edge, segmented around the recesses
  if y<-2.6 or y>-.7:
   tube('Recessed armour panel join',(x,y,bot+.30),(x,y,top-.30),.010,'Radiator',6,bevel=0)
   for z in [bot+.35,top-.35]:bolt((x+sign*.015,y+.065,z),r=.019)
 # Shoulder service conduits route from reactor to the radiator hinge.
 for z in [-.48,-.61]:
  tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
  for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
 tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
 box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
 for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)

\"\"\"+mark
s=s.replace(mark,new)
open(p,'w').write(s)
PY
python3 -m py_compile build_interceptor-hardsf.py
sed -i 's|chrome-linux64/chrome|chrome-linux/chrome|' /tmp/interceptor-runtime/capture.mjs
node /tmp/interceptor-runtime/capture.mjs > /tmp/interceptor-runtime/capture.log 2>&1
cat /tmp/interceptor-runtime/capture.log
cat /tmp/interceptor-runtime/runtime.json" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 26050ms:
{
  "bounds": {
    "min": [
      -3.2507529258728027,
      -1.6027662754058838,
      -7.994504451751709
    ],
    "max": [
      3.250752806663513,
      2.596770763397217,
      8.00416088104248
    ],
    "size": [
      6.501505732536316,
      4.199537038803101,
      15.99866533279419
    ]
  },
  "drawCalls": 20,
  "triangles": 88322,
  "meshes": 18,
  "materials": [
    "Paint",
    "Cockpit",
    "Alloy",
    "Glass",
    "Heatshield",
    "Lamps",
    "Engines",
    "Radiator"
  ],
  "webgl": "WebGL 2.0 (OpenGL ES 3.0 Chromium)",
  "paintFactor": [
    0.55,
    0.56,
    0.55
  ],
  "errors": []
}
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 - <<'PY'
p='build_interceptor-hardsf.py';s=open(p).read()
s=s.replace(\"L.new(bw.outputs[0],ramp.inputs[0])\", \"\"\"L.new(bw.outputs[0],ramp.inputs[0])
 if name=='Heatshield':
  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
\"\"\")
s=s.replace(\"for k in range(len(cr)-1):\", \"mesh('Forward laminated glass end',cr[0],[(0,1,2,3)],'Glass','Canopy')\\nfor k in range(len(cr)-1):\")
a=s.index('RCS=[]\\n');b=s.index('# Fixed ventral railgun:',a)
s=s[:a]+'''RCS=[]
for sx in [-1,1]:
 for sz in [-1,1]:
  c=Vector((sx*1.66,4.29,sz*1.02))
  sphere('RCS titanium pod',c,(.39,.48,.34),'Alloy',segs=24,rings=12)
  jets=[(c+Vector((-.13,.30,0)),Vector((0,1,0))),(c+Vector((.13,.30,0)),Vector((0,1,0))),(c+Vector((sx*.32,.03,0)),Vector((sx,0,0))),(c+Vector((0,.03,sz*.28)),Vector((0,0,sz)))]
  for p,d in jets:
   start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
   lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
   torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
   for o in GROUPS['Hull'][start:]:
    o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
   RCS.append(p+d*.24)
''' + s[b:]
s=s.replace(\"sun.energy=1.7\",\"sun.energy=1.2\")
open(p,'w').write(s)
PY
python3 -m py_compile build_interceptor-hardsf.py
scp build_interceptor-hardsf.py gex44:/root/fal-author/interceptor-hardsf-v1/
ssh gex44 '/root/blender-spike/blender-5.2.0-linux-x64/blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py' > /tmp/interceptor-build-r2.log 2>&1" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cat /tmp/interceptor-critic-r1.txt' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
**Verdict: REVISE**

### Top 5 Visible Findings (Ordered by Impact)

1. **Foreshortened hull proportions, blunt nose, and oversized engine bell flare**
   - **What is visible**: In [interceptor-side-r1.png](file:///tmp/interceptor-side-r1.png), the fuselage is stubby and tall (roughly 2.3:1 length-to-height vs the reference's sleek 3.8:1 ratio in [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg)). The forward nose terminates abruptly right in front of the windscreen in a flat vertical face, and the fusion nozzle flares out as a wide cone that is taller than the hull.
   - **Correction**: Extend and taper the forward nose section ahead of the canopy to match the 16.0m x 4.2m reference silhouette. Reduce the fusion nozzle expansion angle to a tight parabolic bell whose outer rim stays within the dorsal-to-ventral tank height envelope.
   - **Acceptance condition**: Side orthographic view matches the reference length-to-height ratio (3.8:1), with a long tapered nose cone and a nozzle diameter no taller than the outer tank profile.

2. **Open cavity in front of the windscreen and boxy, unintegrated canopy**
   - **What is visible**: In [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) and [interceptor-clay-r1.png](file:///tmp/interceptor-clay-r1.png), the canopy sits inside an open rectangular trough that leaves a gaping hole at the front of the nose and open gaps along the side sills. The canopy itself is a tall, flat-roofed greenhouse box instead of a low teardrop flush with the dorsal spine.
   - **Correction**: Close the forward nose deck so it meets the front windscreen frame seamlessly. Lower the canopy roof by ~35%, angle the side glazing inward, and fairs the rear canopy frame smoothly into the dorsal spine without a vertical step.
   - **Acceptance condition**: Zero visible gaps or open interior troughs around the canopy perimeter in 3/4 and clay views; canopy roofline flows continuously into the nose and spine.

3. **Missing recessed forward flank intakes (replaced by flat surface grilles)**
   - **What is visible**: In [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg), deep trapezoidal intakes are carved into the forward cheeks below the front cockpit, creating the main width step between the mid-hull and the nose. In [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) and [interceptor-side-r1.png](file:///tmp/interceptor-side-r1.png), the hull flanks are flat slabs with two surface-mounted louvered grilles, plus an exposed dark cutout behind the deployed radiator panel.
   - **Correction**: Remove the two pasted-on side grilles. Model the recessed forward cheek intakes into the hull geometry ahead of the radiator bay, and recess a clean housing pocket behind the deployed radiator so the hull wall does not look torn open.
   - **Acceptance condition**: 3/4 and side views show deep structural cheek intakes below the forward cockpit and a finished mechanical recess behind the deployed radiator.

4. **Bright silver hull shader, shading artifact on flank, and missing heat-scorched metal**
   - **What is visible**: In [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) and [interceptor-side-r1.png](file:///tmp/interceptor-side-r1.png), the hull reads as bright, glossy silver-white with a sharp diagonal shading glitch across the forward side panel (between the two grilles). The propellant tanks and fusion nozzle lack the dark gunmetal hull contrast and bronze/blue heat scorching shown in [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg).
   - **Correction**: Fix non-planar faces / custom normals on the side hull to eliminate the diagonal reflection crease. Darken the primary hull material to matte/satin dark gunmetal grey, cut crisp armour panel seams into the hull, and apply bronze/blue heat-scorched metal gradients to the fusion nozzle and tank end-caps.
   - **Acceptance condition**: Side render shows clean, unbroken shading across all hull flats, dark gunmetal hull paint, visible panel seams, and warm heat discoloration on the nozzle and tank domes.

5. **Stepped tank nose cones, detached aft spheres, and undersized chin railgun fairing**
   - **What is visible**: In [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) and [interceptor-side-r1.png](file:///tmp/interceptor-side-r1.png), the four propellant tanks have pointy, stepped bullet tips at the front and separate small spheres at the rear (overlapping the RCS area). Under the nose, the railgun sits on a small, boxy block at the very tip instead of a long ventral weapon fairing.
   - **Correction**: Replace the pointy tank cones and rear spheres with smooth, flush hemispherical dome caps integrated into the tank cylinders, and ensure the 4 aft RCS thruster quads have clear quad-nozzle housings. Lengthen the ventral railgun fairing under the nose and thicken the twin parallel rail barrels.
   - **Acceptance condition**: Tanks have continuous cylindrical bodies with smooth hemispherical caps at both ends, RCS quads read clearly at the aft corners, and the railgun fairing extends under the forward nose chin.

---

0. **Blocked on you**: nothing.
1. **What you changed**: Nothing; this was a read-only visual inspection of the four image files.
2. **How you know it works**: Directly opened and visually inspected [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg), [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png), [interceptor-side-r1.png](file:///tmp/interceptor-side-r1.png), and [interceptor-clay-r1.png](file:///tmp/interceptor-clay-r1.png).
3. **What would break if you were wrong**: If any finding misread a deliberate design choice, the author would spend a revision cycle adjusting geometry that was intended; however, deployed radiators were excluded from findings as instructed.
4. **What you did NOT do**: Did not inspect the `.blend` scene or build scripts, and did not modify any files.

hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'tail -10 /tmp/interceptor-build-r2.log
date -u +%FT%TZ' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:189: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
2026-10-16T18:51:51Z

 exited 143 in 55831ms:
codex
The exported GLB also loads in Three.js without errors, and its runtime paint factor is correct. The revision now has a shorter, lower canopy, fitted cockpit coaming, recessed cheek bays, and continuous tank domes. I also changed the RCS quads so their nozzles point along multiple control axes.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'tail -8 /tmp/interceptor-build-r2.log
scp gex44:/root/fal-author/interceptor-hardsf-v1/final.png /tmp/interceptor-final-r2.png
scp gex44:/root/fal-author/interceptor-hardsf-v1/clay.png /tmp/interceptor-clay-r2.png
scp gex44:/root/fal-author/interceptor-hardsf-v1/side.png /tmp/interceptor-side-r2.png' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 3251ms:
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:189: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha

hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'ps -C blender -o pid,etime,time,pcpu,pmem,args; stat -c \"%y %n\" /root/fal-author/interceptor-hardsf-v1/clay.png'
tail -3 /tmp/interceptor-build-r2.log
date -u +%FT%TZ" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 503ms:
    PID     ELAPSED     TIME %CPU %MEM COMMAND
2026-10-16 20:46:59.524895888 +0200 /root/fal-author/interceptor-hardsf-v1/clay.png
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:189: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
2026-10-16T18:52:41Z

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'ps -eo pid,comm,etime,%cpu,args | grep -F interceptor-hardsf-v1 | head -8'
tail -2 /tmp/interceptor-build-r2.log" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 511ms:
3853765 python3               01:39  0.0 python3 /opt/gpu-gate/gpu_gate.py --name blender --need-mib 6000 --priority batch -- /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
3855853 blender.real          01:14  141 /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
3863244 bash                  00:00  0.0 bash -c ps -eo pid,comm,etime,%cpu,args | grep -F interceptor-hardsf-v1 | head -8
3863246 grep                  00:00  0.0 grep -F interceptor-hardsf-v1
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:189: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "tail -25 /tmp/interceptor-build-r2.log
ssh gex44 'ps -eo pid,comm,etime,%cpu,args | grep -F interceptor-hardsf-v1 | head -8'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 458ms:
[INFO ] mpfb.init ..............................: Build info FROM_SOURCE
[INFO ] mpfb.init ..............................: Online access preference is not enabled. Not checking if MakeHuman is online.
[INFO ] mpfb.init ..............................: MPFB initialization has finished.
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Paint
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Alloy
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Heatshield
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:189: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
3853765 python3               01:44  0.0 python3 /opt/gpu-gate/gpu_gate.py --name blender --need-mib 6000 --priority batch -- /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
3855853 blender.real          01:19  146 /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
3863907 bash                  00:00  0.0 bash -c ps -eo pid,comm,etime,%cpu,args | grep -F interceptor-hardsf-v1 | head -8
3863909 grep                  00:00  0.0 grep -F interceptor-hardsf-v1

 exited 143 in 293101ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "cp build_interceptor-hardsf.py /tmp/interceptor-build-r2.py
python3 - <<'PY'
p='build_interceptor-hardsf.py';s=open(p).read()
s=s.replace(\" uv_project(o)\\n if group\", \" uv_project(o)\\n if mat=='Lamps':\\n  for uv in o.data.uv_layers.active.data:uv.uv=(.125,.5)\\n if group\")
s=s.replace(\"combine.inputs['Red'].default_value=1;L.new(rm.outputs[0]\", \"aomap=N.new('ShaderNodeMapRange');aomap.inputs['To Min'].default_value=.70;aomap.inputs['To Max'].default_value=1;L.new(sep.outputs['Red'],aomap.inputs[0]);L.new(aomap.outputs[0],combine.inputs['Red']);L.new(rm.outputs[0]\")
a=s.index(\" MATS[name]=m;return m\\nspecial('Glass'\")
s=s[:a]+''' # Even constant glass and emissive finishes are Cycles baked for portable appearance.
 if not name.startswith('_'):
  nt=m.node_tree;N=nt.nodes;L=nt.links;out=N.get('Material Output');target=N.new('ShaderNodeTexImage');emit=N.new('ShaderNodeEmission')
  rgb=N.new('ShaderNodeRGB');rgb.outputs[0].default_value=(*color,1)
  source=rgb.outputs[0]
  if name=='Lamps':
   tc=N.new('ShaderNodeTexCoord');sep=N.new('ShaderNodeSeparateXYZ');L.new(tc.outputs['UV'],sep.inputs[0]);ramp=N.new('ShaderNodeValToRGB');L.new(sep.outputs['X'],ramp.inputs[0]);ramp.color_ramp.interpolation='CONSTANT'
   stops=[(0,(.12,.46,.65,1)),(.25,(1,.23,.025,1)),(.5,(1,.012,.005,1)),(.75,(.01,1,.08,1))]
   for i,(pos,c) in enumerate(stops):
    el=ramp.color_ramp.elements[i] if i<2 else ramp.color_ramp.elements.new(pos);el.position=pos;el.color=c
   source=ramp.outputs[0]
  bpy.ops.mesh.primitive_plane_add(size=2);o=bpy.context.object;o.data.materials.append(m)
  baked={}
  for kind in ['Base','Normal','ORM']:
   im=bpy.data.images.new(f'{name}_{kind}',64,64,alpha=False)
   if kind"'!='"'Base':im.colorspace_settings.name='Non-Color'
   target.image=im;N.active=target
   if kind=='Normal':L.new(bs.outputs[0],out.inputs[0]);typ='NORMAL'
   else:
    if kind=='Base':L.new(source,emit.inputs['Color'])
    else:
     for link in list(emit.inputs['Color'].links):L.remove(link)
     emit.inputs['Color'].default_value=(1,rough,metal,1)
    L.new(emit.outputs[0],out.inputs[0]);typ='EMIT'
   active(o);sc.cycles.samples=4;bpy.ops.object.bake(type=typ);im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();baked[kind]=im
  bpy.data.objects.remove(o,do_unlink=True);L.new(bs.outputs[0],out.inputs[0])
  for kind,im in baked.items():
   n=N.new('ShaderNodeTexImage');n.image=im
   if kind=='Base':
    L.new(n.outputs[0],bs.inputs['Base Color'])
    if emission:L.new(n.outputs[0],bs.inputs['Emission Color'])
   elif kind=='Normal':
    nm=N.new('ShaderNodeNormalMap');L.new(n.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
   else:
    sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
  BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
 MATS[name]=m;return m
special('Glass' '''+s[a+len(\" MATS[name]=m;return m\\nspecial('Glass'\"):]
s=s.replace(\" tube('Canopy actuator body',(s*.79,.65,1.23),(s*.82,-.30,1.16),.055,'Heatshield')\\n tube('Canopy actuator rod',(s*.82,-.30,1.16),(s*.77,-.83,1.17),.029,'Alloy')\", \" tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)\")
s=s.replace(\"  tube('Radiator deployment arm',(s*2.04,y,-.27),P(.62,y,-.04),.045,'Heatshield',12,group)\", \"  tube('Radiator rotary actuator',(s*2.15,y-.09,.26),(s*2.15,y+.09,.26),.13,'Heatshield',20)\")
s=s.replace(\" sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)\", \" nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)\\n for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)\")
s=s.replace(\"sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)\", \"beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)\\nfor uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)\")
s=s.replace(\"'Canopy':[1,0,0],'Radiator_L':[0,0,1],'Radiator_R':[0,0,1]\", \"'Canopy':[-1,0,0],'Radiator_L':[0,0,1],'Radiator_R':[0,0,-1]\")
s=s.replace(\"o.rotation_euler[0 if n=='Canopy' else 1]=math.radians(30)\", \"o.rotation_euler[0 if n=='Canopy' else 1]=math.radians(30 if n=='Radiator_R' else -30)\")
s=s.replace(\"sc.cycles.samples=32\", \"sc.cycles.samples=48\")
s=s.replace(\"def render(path,w=1920,h=1080,samples=32)\", \"def render(path,w=1920,h=1080,samples=48)\")
s=s.replace(\"'Shared Cycles-baked tiling colour/normal 1024 px, packed ORM 512 px; Paint neutral texture × linear faction factor.'\", \"'Cycles baked: shared tiling colour/normal 1024 px, ORM 512 px; optical/emissive 64 px; neutral Paint texture × faction factor.'\")
open(p,'w').write(s)
PY
python3 -m py_compile build_interceptor-hardsf.py
ssh gex44 'ps -eo pid,comm,etime,%cpu,args | grep -F interceptor-hardsf-v1 | head -8; stat -c \"%y %n\" /root/fal-author/interceptor-hardsf-v1/final.png'
tail -3 /tmp/interceptor-build-r2.log" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 928ms:
3870111 bash                  00:00  0.0 bash -c ps -eo pid,comm,etime,%cpu,args | grep -F interceptor-hardsf-v1 | head -8; stat -c "%y %n" /root/fal-author/interceptor-hardsf-v1/final.png
3870114 grep                  00:00  0.0 grep -F interceptor-hardsf-v1
2026-10-16 20:47:06.671949734 +0200 /root/fal-author/interceptor-hardsf-v1/final.png
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:189: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "scp build_interceptor-hardsf.py gex44:/root/fal-author/interceptor-hardsf-v1/
ssh gex44 'nohup /root/blender-spike/blender-5.2.0-linux-x64/blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py > /root/fal-author/interceptor-hardsf-v1/evidence/build.log 2>&1 < /dev/null & echo "'$!'"'
date -u +%FT%TZ" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 1428ms:
3871397
2026-10-16T18:53:55Z

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -18 /root/fal-author/interceptor-hardsf-v1/evidence/build.log; ps -eo pid,etime,%cpu,args | grep -F interceptor-hardsf-v1/build_interceptor | head -4'
wc -l build_interceptor-hardsf.py
df -h /" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 567ms:
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Alloy
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Heatshield
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:191: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
3871397       00:43  0.1 python3 /opt/gpu-gate/gpu_gate.py --name blender --need-mib 6000 --priority batch -- /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
3871890       00:33  131 /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
3874362       00:00  0.0 bash -c tail -18 /root/fal-author/interceptor-hardsf-v1/evidence/build.log; ps -eo pid,etime,%cpu,args | grep -F interceptor-hardsf-v1/build_interceptor | head -4
3874365       00:00  0.0 grep -F interceptor-hardsf-v1/build_interceptor
593 build_interceptor-hardsf.py
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1       150G  136G  7.9G  95% /

context compacted
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 28 /root/fal-author/interceptor-hardsf-v1/evidence/build.log; ps -eo pid,etime,pcpu,args | grep -F interceptor-hardsf-v1 | grep -v grep'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 exited 1 in 570ms:
20:55:45 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
20:55:45 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
20:55:45 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
20:55:45 | INFO: Primitives created: 8
20:55:45 | INFO: Extracting primitive: Forward laminated glass end
20:55:45 | INFO: Primitives created: 4
20:55:45 | INFO: Extracting primitive: Radiator cassette
20:55:45 | INFO: Primitives created: 3
20:55:45 | INFO: Extracting primitive: Radiator cassette.005
20:55:45 | INFO: Primitives created: 3
20:55:45 | INFO: Finished glTF 2.0 export in 0.26047778129577637 s

Info: Saved as "interceptor.blend"
FIRST_GLB_COMPLETE 98.6 7819952
01:48.440  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/final.png'
01:56.975  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/side.png'
02:01.214  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/rear.png'
02:06.118  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/cockpit.png'
02:13.131  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/pivot_test.png'
02:14.989  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Naval_grey.png'
02:16.343  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Gunmetal.png'
02:17.758  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Olive_drab.png'
02:19.082  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Desert_sand.png'
02:20.455  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Arctic_white.png'
02:21.775  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Deep_navy.png'
BUILD_COMPLETE 141.2 87996 7819952

Blender quit

hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/final.png /tmp/interceptor-final-r2.png' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 991ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/evidence/rear.png gex44:/root/fal-author/interceptor-hardsf-v1/evidence/cockpit.png gex44:/root/fal-author/interceptor-hardsf-v1/evidence/pivot_test.png /tmp/interceptor-runtime/' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/side.png gex44:/root/fal-author/interceptor-hardsf-v1/clay.png gex44:/root/fal-author/interceptor-hardsf-v1/paints.png /tmp/interceptor-runtime/' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 2932ms:
 succeeded in 2962ms:
hook: PreToolUse
hook: PreToolUse Completed
codex
The revised asset is complete and within budget at 88,000 triangles and 7.8 MB. The new render shows two areas to refine: the windscreen-to-nose junction and the large, overly smooth hull panels. I’m tightening those details before final validation.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "sed -n '1,245p' build_interceptor-hardsf.py" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
"""Forge hard-SF interceptor. Blender 5.2, clean-scene build, CC0 tile bakes, GLB and evidence renders.
Run: blender -b --python-exit-code 1 --python build_interceptor-hardsf.py
Coordinates during authoring: X left, -Y forward, Z up. Export converts to X left, Y up, Z forward.
Scope: 16 x 6.5 x 4.2m; 40–90k tris; <=9 mats; 1024 colour/normal, 512 ORM; <=12MB.
Views: hero front-left, ortho side, clay, six paints, 30-degree hinge test. Revision cap: 3.
"""
import bpy, math, os, sys, json, urllib.request, subprocess, struct, time
import numpy as np
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
EVID=ROOT/'evidence'; EVID.mkdir(exist_ok=True)
START=time.time()
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene
sc.unit_settings.system='METRIC';sc.unit_settings.scale_length=1
sc.render.engine='CYCLES';sc.cycles.device='GPU';sc.cycles.samples=48
prefs=bpy.context.preferences.addons['cycles'].preferences
for backend in ['OPTIX','CUDA']:
 try:
  prefs.compute_device_type=backend;prefs.get_devices()
  if any(d.type==backend for d in prefs.devices):
   for d in prefs.devices:d.use=d.type==backend
   break
 except Exception:pass
else:raise RuntimeError('CUDA/OptiX GPU required')
sc.cycles.use_denoising=True;sc.render.image_settings.file_format='PNG'
sc.view_settings.view_transform='AgX'
sc.render.resolution_percentage=100
sc.render.film_transparent=False
GROUPS={'Hull':[],'Canopy':[],'Radiator_L':[],'Radiator_R':[]}
MATS={}; BAKE_LOG=[]

def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def uv_project(o):
 uv=o.data.uv_layers.new(name='UVMap') if not o.data.uv_layers else o.data.uv_layers.active
 for f in o.data.polygons:
  n=f.normal;axis=max(range(3),key=lambda i:abs(n[i]));a,b=([1,2] if axis==0 else [0,2] if axis==1 else [0,1])
  for li in f.loop_indices:
   p=o.data.vertices[o.data.loops[li].vertex_index].co
   uv.data[li].uv=(p[a]*.57,p[b]*.57)

def finish(o,name,mat,group='Hull',bevel=0,smooth=False):
 o.name=name
 if mat:o.data.materials.append(MATS[mat] if isinstance(mat,str) else mat)
 if bevel:
  active(o);m=o.modifiers.new('Machined edge radii','BEVEL');m.width=bevel;m.segments=2
  bpy.ops.object.modifier_apply(modifier=m.name)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 else:
  # Weighted face normals preserve armour planes while letting chamfers catch highlights.
  active(o)
  if bevel:
   for p in o.data.polygons:p.use_smooth=True
   m=o.modifiers.new('Weighted normals','WEIGHTED_NORMAL');m.keep_sharp=True;m.weight=50
   bpy.ops.object.modifier_apply(modifier=m.name)
 uv_project(o)
 if mat=='Lamps':
  for uv in o.data.uv_layers.active.data:uv.uv=(.125,.5)
 if group:GROUPS[group].append(o)
 return o

def mesh(name,verts,faces,mat,group='Hull',bevel=0,smooth=False):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 o=bpy.data.objects.new(name,me);sc.collection.objects.link(o)
 # Make connected surfaces outward-facing.
 import bmesh
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 return finish(o,name,mat,group,bevel,smooth)

def box(name,loc,size,mat='Paint',bevel=.025,group='Hull'):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object
 o.data.transform(Matrix.Diagonal(Vector((*size,1))));return finish(o,name,mat,group,bevel)

def sphere(name,loc,scale,mat='Alloy',group='Hull',segs=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=segs,ring_count=rings,radius=1,location=loc);o=bpy.context.object
 o.data.transform(Matrix.Diagonal(Vector((*scale,1))));return finish(o,name,mat,group,0,True)

def tube(name,a,b,r,mat='Alloy',vertices=12,group='Hull',bevel=.008):
 a,b=Vector(a),Vector(b);d=b-a
 bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=r,depth=d.length,location=(a+b)/2)
 o=bpy.context.object;o.rotation_euler=d.to_track_quat('Z','Y').to_euler()
 o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
 return finish(o,name,mat,group,bevel,True)

def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
 bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
 o=bpy.context.object
 if axis=='Y':o.data.transform(Matrix.Rotation(pi/2,4,'X'))
 elif axis=='X':o.data.transform(Matrix.Rotation(pi/2,4,'Y'))
 return finish(o,name,mat,group,0,True)

def bolt(loc,axis='X',group='Hull',r=.026):
 p=Vector(loc);v=Vector((1,0,0) if axis=='X' else (0,1,0) if axis=='Y' else (0,0,1))
 return tube('Captive fastener',p-v*.012,p+v*.012,r,'Alloy',6,group,0)

def lathe(name,profile,center,mat,group='Hull',segments=64,axis='Y'):
 # profile is axial-position, radius; can include outer and inner contour for real bell thickness
 vs=[]
 for y,r in profile:
  for j in range(segments):
   a=2*pi*j/segments;p=Vector((r*cos(a),y,r*sin(a)))
   if axis=='X':p=Vector((y,r*cos(a),r*sin(a)))
   vs.append(p+Vector(center))
 fs=[]
 for k in range(len(profile)-1):
  for j in range(segments):fs.append((k*segments+j,k*segments+(j+1)%segments,(k+1)*segments+(j+1)%segments,(k+1)*segments+j))
 return mesh(name,vs,fs,mat,group,0,True)

def download(asset,mapname):
 p=TEX/f'{asset}_{mapname}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mapname}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
  try:p.write_bytes(urllib.request.urlopen(req,timeout=90).read())
  except Exception:subprocess.run(['curl','-L','--fail','-A','Mozilla/5.0',url,'-o',str(p)],check=True)
 return p

# The two CC0 source sets are shared by every opaque surface. No image-generation sources.
PH={}
for asset in ['metal_plate','metal_plate_02']:
 for k in ['diff','nor_gl','arm']:
  im=bpy.data.images.load(str(download(asset,k)),check_existing=True)
  if k!='diff':im.colorspace_settings.name='Non-Color'
  PH[asset,k]=im

def bake_material(name,color,metal,rough,asset='metal_plate',relief=.1,neutral=False):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();N=nt.nodes;L=nt.links
 out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=N.new('ShaderNodeTexCoord')
 def tex(k):
  n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
 diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
 ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
 low=.58 if neutral else .62;high=.94 if neutral else 1.15
 ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
 ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
 L.new(bw.outputs[0],ramp.inputs[0])
 if name=='Heatshield':
  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)

 noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
 pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
 L.new(noise.outputs['Fac'],pits.inputs[0]);mix=N.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.40
 L.new(ramp.outputs[0],mix.inputs[1]);L.new(pits.outputs[0],mix.inputs[2]);L.new(mix.outputs[0],bs.inputs['Base Color'])
 sep=N.new('ShaderNodeSeparateColor');L.new(arm.outputs['Color'],sep.inputs[0])
 rm=N.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=max(.15,rough-.10);rm.inputs['To Max'].default_value=min(.85,rough+.15);L.new(sep.outputs['Green'],rm.inputs[0]);L.new(rm.outputs[0],bs.inputs['Roughness'])
 bs.inputs['Metallic'].default_value=metal
 nm=N.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=relief;L.new(normal.outputs['Color'],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,0));o=bpy.context.object;o.name='Tile bake fixture';o.data.materials.append(m)
 target=N.new('ShaderNodeTexImage');N.active=target
 emit=N.new('ShaderNodeEmission');combine=N.new('ShaderNodeCombineColor');aomap=N.new('ShaderNodeMapRange');aomap.inputs['To Min'].default_value=.70;aomap.inputs['To Max'].default_value=1;L.new(sep.outputs['Red'],aomap.inputs[0]);L.new(aomap.outputs[0],combine.inputs['Red']);L.new(rm.outputs[0],combine.inputs['Green']);combine.inputs['Blue'].default_value=metal
 baked={}
 for kind,res in [('Base',1024),('Normal',1024),('ORM',512)]:
  im=bpy.data.images.new(f'{name}_{kind}',res,res,alpha=False)
  if kind!='Base':im.colorspace_settings.name='Non-Color'
  target.image=im;N.active=target
  if kind=='Normal':L.new(bs.outputs[0],out.inputs['Surface']);typ='NORMAL'
  else:
   L.new(mix.outputs[0] if kind=='Base' else combine.outputs[0],emit.inputs['Color']);L.new(emit.outputs[0],out.inputs['Surface']);typ='EMIT'
  active(o);sc.cycles.samples=8;sc.render.bake.margin=8;bpy.ops.object.bake(type=typ)
  im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();baked[kind]=im
 bpy.data.objects.remove(o,do_unlink=True)
 nt.nodes.clear();out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs[0],out.inputs[0]);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 for kind in ['Base','Normal','ORM']:
  n=N.new('ShaderNodeTexImage');n.image=baked[kind];n.label='Cycles baked shared tile'
  if kind=='Base':
   if name=='Paint':
    tint=N.new('ShaderNodeMixRGB');tint.name='Faction paint factor';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;tint.inputs[2].default_value=(.55,.56,.55,1);L.new(n.outputs[0],tint.inputs[1]);L.new(tint.outputs[0],bs.inputs['Base Color'])
   else:L.new(n.outputs[0],bs.inputs['Base Color'])
  elif kind=='Normal':
   nm=N.new('ShaderNodeNormalMap');L.new(n.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   # glTF occlusion channel, packed with roughness and metalness.
   ng=bpy.data.node_groups.get('glTF Material Output')
   if not ng:
    ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   gn=N.new('ShaderNodeGroup');gn.node_tree=ng;L.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
 print('BAKED',name,flush=True)

for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)

def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
 m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
 if emission:bs.inputs['Emission Color'].default_value=(*color,1);bs.inputs['Emission Strength'].default_value=emission
 if alpha<1:
  m.surface_render_method='BLENDED';m.use_transparent_shadow=True
 # Even constant glass and emissive finishes are Cycles baked for portable appearance.
 if not name.startswith('_'):
  nt=m.node_tree;N=nt.nodes;L=nt.links;out=N.get('Material Output');target=N.new('ShaderNodeTexImage');emit=N.new('ShaderNodeEmission')
  rgb=N.new('ShaderNodeRGB');rgb.outputs[0].default_value=(*color,1)
  source=rgb.outputs[0]
  if name=='Lamps':
   tc=N.new('ShaderNodeTexCoord');sep=N.new('ShaderNodeSeparateXYZ');L.new(tc.outputs['UV'],sep.inputs[0]);ramp=N.new('ShaderNodeValToRGB');L.new(sep.outputs['X'],ramp.inputs[0]);ramp.color_ramp.interpolation='CONSTANT'
   stops=[(0,(.12,.46,.65,1)),(.25,(1,.23,.025,1)),(.5,(1,.012,.005,1)),(.75,(.01,1,.08,1))]
   for i,(pos,c) in enumerate(stops):
    el=ramp.color_ramp.elements[i] if i<2 else ramp.color_ramp.elements.new(pos);el.position=pos;el.color=c
   source=ramp.outputs[0]
  bpy.ops.mesh.primitive_plane_add(size=2);o=bpy.context.object;o.data.materials.append(m)
  baked={}
  for kind in ['Base','Normal','ORM']:
   im=bpy.data.images.new(f'{name}_{kind}',64,64,alpha=False)
   if kind!='Base':im.colorspace_settings.name='Non-Color'
   target.image=im;N.active=target
   if kind=='Normal':L.new(bs.outputs[0],out.inputs[0]);typ='NORMAL'
   else:
    if kind=='Base':L.new(source,emit.inputs['Color'])
    else:
     for link in list(emit.inputs['Color'].links):L.remove(link)
     emit.inputs['Color'].default_value=(1,rough,metal,1)
    L.new(emit.outputs[0],out.inputs[0]);typ='EMIT'
   active(o);sc.cycles.samples=4;bpy.ops.object.bake(type=typ);im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();baked[kind]=im
  bpy.data.objects.remove(o,do_unlink=True);L.new(bs.outputs[0],out.inputs[0])
  for kind,im in baked.items():
   n=N.new('ShaderNodeTexImage');n.image=im
   if kind=='Base':
    L.new(n.outputs[0],bs.inputs['Base Color'])
    if emission:L.new(n.outputs[0],bs.inputs['Emission Color'])
   elif kind=='Normal':
    nm=N.new('ShaderNodeNormalMap');L.new(n.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
   else:
    sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
  BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
 MATS[name]=m;return m
special('Glass' ,(.19,.30,.36),.18,.14,.30)
special('Engines',(.12,.28,.65),.45,.26,1,1.8)
special('Lamps',(.21,.64,.72),.25,.25,1,2)

# Main pressure/armour shell; longitudinal stations define the silhouette.
SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
def ring(y,w,bot,top):
 return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
vs=[]
for s in SECTIONS:vs+=ring(*s)
fs=[]
for k in range(len(SECTIONS)-1):
 for j in range(8):
  if j==0 and 1<=k<=4:continue # actual cockpit aperture, no opaque deck behind glazing

hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "sed -n '245,490p' build_interceptor-hardsf.py" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
  if j==0 and 1<=k<=4:continue # actual cockpit aperture, no opaque deck behind glazing
  fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
fs += [tuple(reversed(range(8))),tuple(range(56,64))]
hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
# Low armoured dorsal spine behind the cockpit.
vs=[]
for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
fs=[tuple(reversed(range(8))),tuple(range(24,32))]
for k in range(3):
 for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
mesh('Dorsal reactor shielding',vs,fs,'Paint',bevel=.065)
# Recessed vent bays and fitted armour tiles along the shoulder.
for sign in [-1,1]:
 for k,(ya,yb) in enumerate([(-.05,.65),(.70,2.1),(2.15,3.65),(3.70,4.35)]):
  x=sign*(2.13 if ya>=-.95 else 1.78)
  box('Inset thermal service bay',(x,(ya+yb)/2,.21),(.14,yb-ya,.78),'Cockpit',.06)
  for z in [-.25,.69]:tube('Service bay rim',(x*1.012,ya,z),(x*1.012,yb,z),.045,'Paint')
  for y in [ya,yb]:tube('Service bay rim',(x*1.012,y,-.25),(x*1.012,y,.69),.045,'Paint')
  for j in range(9):
   y=ya+.11+(yb-ya-.22)*j/8
   box('Heat exchanger louvre',(x*1.025,y,.21),(.09,.055,.65),'Alloy',.012)
  for y in [ya+.09,yb-.09]:
   for z in [-.19,.62]:bolt((x*1.04,y,z))
 # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
 for y in [.15,1.25,2.5,3.65]:
  x=sign*1.39
  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
  for xx in [-.17,.17]:
   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
 # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
 vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
 ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
 cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
 # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
 cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
 for target in [hull_shell,cheek]:
  active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
 bpy.data.objects.remove(cutter,do_unlink=True)
 box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
 for j in range(8):
  box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
 # Framed opening and surrounding armour remain continuous.
 for y in [-2.54,-1.20]:
  x=sign*(1.62 if y<-2 else 1.95)
  tube('Cheek aperture vertical rim',(x,y,.10),(x,y,.64),.032,'Alloy',10)

 # Sensor blister: seat is tangent to nose flank, optical aperture points forward.
 sphere('Protected sensor blister',(sign*.83,-4.42,.20),(.22,.52,.17),'Alloy',segs=24,rings=12)
 tube('Sensor optical hood',(sign*.83,-4.88,.20),(sign*.83,-4.98,.20),.085,'Cockpit',24)
 tube('Sensor glass',(sign*.83,-4.985,.20),(sign*.83,-4.993,.20),.061,'Glass',24,bevel=.002)
# Panel seams with paired armoured ribs, belly doors closed.
for y,w,bot,top in SECTIONS[2:-1]:
 for sign in [-1,1]:
  tube('Armour seam', (sign*w*.69,y,bot+.04),(sign*w*.99,y,bot+.28),.015,'Alloy',8)
  for z in [bot+.35,top-.36]:bolt((sign*(w+.006),y,z),r=.024)
for y in [-1.8,.2,2.2]:
 box('Closed ventral equipment door',(0,y,-1.045),(1.3,1.8,.07),'Alloy',.07)
 box('Flush ventral armour',(0,y,-1.09),(1.19,1.69,.04),'Paint',.06)
 for x in [-.48,.48]:
  for yy in [-.66,.66]:bolt((x,y+yy,-1.12),'Z')

# Armour seams follow the longitudinal loft, held just proud of the skin (not z-fighting).
for sign in [-1,1]:
 for y in [-4.82,-3.55,-2.82,-.86,1.04,2.72,3.85]:
  # interpolate the outer side face at this longitudinal station
  for a,b in zip(SECTIONS,SECTIONS[1:]):
   if a[0]<=y<=b[0]:
    t=(y-a[0])/(b[0]-a[0]);w=a[1]*(1-t)+b[1]*t;bot=a[2]*(1-t)+b[2]*t;top=a[3]*(1-t)+b[3]*t;break
  x=sign*(w+.009)
  # narrow dark join with a fine metal edge, segmented around the recesses
  if y<-2.6 or y>-.7:
   tube('Recessed armour panel join',(x,y,bot+.30),(x,y,top-.30),.010,'Radiator',6,bevel=0)
   for z in [bot+.35,top-.35]:bolt((x+sign*.015,y+.065,z),r=.019)
 # Shoulder service conduits route from reactor to the radiator hinge.
 for z in [-.48,-.61]:
  tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
  for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
 tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
 box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
 for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)

# Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
for sx in [-1,1]:
 for sz in [-1,1]:
  x=sx*1.46;z=1.39 if sz==1 else -1.06
  cap=[]
  for j in range(13):
   a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
  cap.append((3.52,.49))
  for j in range(1,13):
   a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)

  for y in [1.28,3.52]:
   torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
   torus('Retaining band lip',(x,y-.04,z),.499,.012,'Alloy',segs=48)
   box('Tank saddle',(x,y,z-sz*.40),(.72,.18,.25),'Paint',.035)
   for dx in [-.24,.24]:bolt((x+dx,y,z+sz*.49),'Z',r=.023)
  tube('Propellant feed',(x,4.17,z),(sx*1.17,4.85,sz*.63),.065,'Heatshield',16)
  torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
# Dorsal antenna/sensor covers (no markings).
for y in [1.35,3.5]:
 sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)

# Cockpit tub open beneath the transparent hinged canopy.
cockpit_start=len(GROUPS['Hull'])
box('Cockpit floor',(0,-1.55,.73),(1.46,3.9,.14),'Cockpit',.06)
for y in [-2.25,-.65]:
 box('Seat pedestal',(0,y,.98),(.48,.53,.42),'Alloy',.06)
 box('Crashworthy seat cushion',(0,y,1.23),(.59,.69,.19),'Cockpit',.09)
 back=box('Seat back',(0,y+.29,1.67),(.59,.18,.81),'Cockpit',.085);back.rotation_euler.x=-.13
 box('Head restraint',(0,y+.32,2.12),(.40,.22,.27),'Cockpit',.08)
 for s in [-1,1]:
  tube('Seat frame',(s*.32,y+.30,1.12),(s*.32,y+.34,2.05),.035,'Alloy')
  tube('Harness webbing',(s*.14,y+.175,1.35),(s*.20,y+.16,2.00),.027,'Heatshield',8)
  box('Side console',(s*.55,y-.12,1.37),(.24,.80,.25),'Cockpit',.05)
  for j in range(5):box('Console switch',(s*.55,y-.39+j*.12,1.505),(.04,.04,.025),'Alloy',.005)
 tube('Control stick',(0,y-.27,1.27),(0,y-.40,1.55),.032,'Alloy')
 sphere('Hand grip',(0,y-.40,1.56),(.055,.06,.09),'Cockpit',segs=12,rings=8)
 # Instrument panel face sits ahead of each operator.
 box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
 for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
# Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
# Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
def interp(y,rows):
 if y<=rows[0][0]:return rows[0][1:]
 if y>=rows[-1][0]:return rows[-1][1:]
 for a,b in zip(rows,rows[1:]):
  if a[0]<=y<=b[0]:
   t=(y-a[0])/(b[0]-a[0]);return tuple(a[j]*(1-t)+b[j]*t for j in range(1,len(a)))
yys=sorted(set([-5.35,-4.25,-2.9,-1.1,.70]+[r[0] for r in CAN]))
for sign in [-1,1]:
 for ya,yb in zip(yys,yys[1:]):
  vv=[]
  for y in [ya,yb]:
   w,bot,top=interp(y,SECTIONS);cw,cb,ct=interp(y,CAN)
   if y<CAN[0][0]:cw=0;cb=top+.012
   vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
  o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
  active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
 for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)

# each station left sill, left roof, right roof, right sill
cr=[]
for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
mesh('Forward laminated glass end',cr[0],[(0,1,2,3)],'Glass','Canopy')
for k in range(len(cr)-1):
 for j in range(3):
  v=[cr[k][j],cr[k][j+1],cr[k+1][j+1],cr[k+1][j]]
  o=mesh('Armoured glazing facet',v,[(0,1,2,3)],'Glass','Canopy')
  active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
for j in range(4):
 for k in range(4):
  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
for k in range(5):
 for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
 for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
for s in [-1,1]:
 tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
 tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)

# Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
for y,r in [(4.56,1.01),(4.91,.94),(5.40,.57)]:torus('Reactor flange',(0,y,0),r,.065,'Alloy',segs=72)
for j in range(12):
 a=2*pi*j/12;d=Vector((cos(a),0,sin(a)))
 tube('Thrust frame strut',d*1.24+Vector((0,4.30,0)),d*.82+Vector((0,5.34,0)),.07,'Alloy',12)
 for y,r in [(4.55,1.12),(4.83,1.03)]:
  box('Magnet actuator',(r*cos(a),y,r*sin(a)),(.15,.27,.15),'Alloy',.02)
profile=[(5.45,.53),(5.65,.59),(5.95,.76),(6.3,.98),(6.7,1.21),(7.12,1.39),(7.56,1.52),(7.94,1.59),(8.0,1.59),(8.0,1.52),(7.56,1.45),(7.12,1.32),(6.7,1.14),(6.3,.91),(5.95,.69),(5.65,.52),(5.45,.46)]
lathe('Regeneratively cooled fusion bell',profile,(0,0,0),'Heatshield',segments=96)
for y,r in [(5.68,.61),(6.32,1.0),(7.13,1.41),(7.94,1.59)]:torus('Bell reinforcing hoop',(0,y,0),r,.036,'Alloy',segs=96)
for j in range(40):
 a=j*2*pi/40
 for (ya,ra),(yb,rb) in zip(profile[:8],profile[1:8]):
  tube('Bell cooling channel',((ra+.012)*cos(a),ya,(ra+.012)*sin(a)),((rb+.012)*cos(a),yb,(rb+.012)*sin(a)),.018,'Alloy',6,bevel=0)
for y,r in [(5.48,.56),(5.63,.59)]:torus('Copper coolant manifold',(0,y,0),r,.072,'Heatshield',segs=72)
lathe('Luminous fusion throat',[(5.46,.45),(5.47,.34),(5.48,.05)],(0,0,0),'Engines',segments=64)
torus('Modest ion sheath',(0,7.91,0),1.505,.023,'Engines',segs=96)
# Four RCS quads, four nozzles per block, all anchored near the rear hardpoints.
RCS=[]
for sx in [-1,1]:
 for sz in [-1,1]:
  c=Vector((sx*1.66,4.29,sz*1.02))
  sphere('RCS titanium pod',c,(.39,.48,.34),'Alloy',segs=24,rings=12)
  jets=[(c+Vector((-.13,.30,0)),Vector((0,1,0))),(c+Vector((.13,.30,0)),Vector((0,1,0))),(c+Vector((sx*.32,.03,0)),Vector((sx,0,0))),(c+Vector((0,.03,sz*.28)),Vector((0,0,sz)))]
  for p,d in jets:
   start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
   lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
   torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
   for o in GROUPS['Hull'][start:]:
    o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
   RCS.append(p+d*.24)
# Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
for s in [-1,1]:
 box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
 for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
 tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
box('Muzzle collar',(0,-7.94,-.64),(.39,.12,.32),'Alloy',.025)
box('Muzzle bore',(0,-8.005,-.64),(.145,.012,.115),'Cockpit',.015)
# Deployed folding radiators: five replaceable carbon emitter cassettes per side.
for s,group in [(1,'Radiator_L'),(-1,'Radiator_R')]:
 # Transform from panel coordinates (u along deployment span, v fore/aft).
 def P(u,v,h=0):return Vector((s*(2.16+u*.86),v,.26-u*.55+h))
 for y in [-.05,.95,2.0,3.05,4.10]:
  tube('Radiator hinge axle',(s*2.15,y-.13,.26),(s*2.15,y+.13,.26),.09,'Alloy',20)
  tube('Radiator rotary actuator',(s*2.15,y-.09,.26),(s*2.15,y+.09,.26),.13,'Heatshield',20)
 for i in range(5):
  ya=-.12+i*.85;yb=ya+.81
  vv=[P(u,v,h) for h in [-.045,.045] for u,v in [(0,ya),(1.267,ya),(1.267,yb),(0,yb)]]
  mesh('Radiator cassette',vv,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Radiator',group,.014)
  for u in [0,1.267]:tube('Radiator cassette edging',P(u,ya,.05),P(u,yb,.05),.028,'Alloy',8,group)
  for y in [ya,yb]:tube('Radiator collector',P(0,y,.05),P(1.267,y,.05),.026,'Alloy',8,group)
  for j in range(16):
   v=ya+.06+j*.046
   tube('Radiator capillary',P(.06,v,.052),P(1.20,v,.052),.011,'Heatshield',6,group,0)
  for u in [.04,1.225]:
   for y in [ya+.04,yb-.04]:sphere('Radiator captive screw',P(u,y,.08),(.019,.019,.019),'Alloy',group,segs=8,rings=4)
# Hull-mounted lights and hardpoint shoes.
for s in [-1,1]:
 box('Nav light housing',(s*2.18,3.72,.66),(.18,.34,.16),'Alloy',.04)
 nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
 for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
 box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)

# Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
# never apply transforms to these moving nodes.
PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
OBJECTS={}
for name,objs in GROUPS.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in objs:o.select_set(True)
 bpy.context.view_layer.objects.active=objs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 pivot=Vector(PIVOTS[name]);world=o.matrix_world.copy()
 # Bake each joined mesh's world transform into vertices, preserving the authored moving origin.
 o.data.transform(Matrix.Translation(-pivot)@world);o.matrix_world=Matrix.Translation(pivot)
 # Remove zero-area faces before export.

exec
/bin/bash -lc "sed -n '490,650p' build_interceptor-hardsf.py" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
 # Remove zero-area faces before export.
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-10];bmesh.ops.delete(bm,geom=dead,context='FACES');bm.to_mesh(o.data);bm.free()
 OBJECTS[name]=o
for name in ['Canopy','Radiator_L','Radiator_R']:OBJECTS[name].parent=OBJECTS['Hull']
# Helpers: empty sockets are exported, display objects are not.
def socket(name,p,parent='Hull'):
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
socket('Thruster_1',(0,8,0))
for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
socket('Muzzle_1',(0,-8.02,-.64))
for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
# Exact overall dimensions: apply a single coordinate correction to data and node locations,
# not object transforms, preserving all hinge frames.
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(v) for o in OBJECTS.values() for v in o.bound_box]
lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
S=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)))
SM=Matrix.Diagonal(Vector((*S,1)))
for o in list(sc.objects):
 if o.type=='MESH':o.data.transform(SM)
 o.location=Vector([o.location[i]*S[i] for i in range(3)])
bpy.context.view_layer.update()
for o in OBJECTS.values():
 active(o);tri=o.modifiers.new('Export triangulation','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
if triangles>90000:
 for o in OBJECTS.values():
  active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
 triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
print('GEOMETRY',triangles,'triangles',flush=True)

# Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.
world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world
nt=world.node_tree;nt.nodes.clear();N=nt.nodes;L=nt.links
out=N.new('ShaderNodeOutputWorld');bg=N.new('ShaderNodeBackground');bg.inputs['Strength'].default_value=.24
coord=N.new('ShaderNodeTexCoord');sep=N.new('ShaderNodeSeparateXYZ');L.new(coord.outputs['Normal'],sep.inputs[0]);r=N.new('ShaderNodeValToRGB');r.color_ramp.elements[0].position=0;r.color_ramp.elements[0].color=(.003,.006,.014,1);r.color_ramp.elements[1].position=1;r.color_ramp.elements[1].color=(.025,.043,.075,1);L.new(sep.outputs['Z'],r.inputs[0]);L.new(r.outputs[0],bg.inputs[0]);L.new(bg.outputs[0],out.inputs[0])
def area(name,loc,power,color,size,target=(0,0,0)):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.color=color;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler()
area('Upper front white key',(3,-8,12),2500,(1,.97,.92),7)
area('Cool soft fill',(-8,-1,6),1800,(.64,.78,1),8)
area('Engine rim',(1,9,8),2900,(.72,.83,1),6)
area('Soft belly fill',(3,-2,-6),700,(.66,.73,1),6)
sun=bpy.data.lights.new('Sunlight','SUN');sun.energy=1.2;sun.angle=.08;sun.color=(1,.98,.95);so=bpy.data.objects.new('Sunlight',sun);sc.collection.objects.link(so);so.rotation_euler=(.4,-.5,-.4)
camd=bpy.data.cameras.new('Evidence camera');cam=bpy.data.objects.new('Evidence camera',camd);sc.collection.objects.link(cam);sc.camera=cam
HERO=(15,-22,14)
def camera(loc,target=(0,0,.1),ortho=19.0):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=ortho
camera(HERO)
def render(path,w=1920,h=1080,samples=48):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.cycles.samples=samples;sc.render.filepath=str(path);bpy.ops.render.render(write_still=True)
# First a clay gate with all silhouette geometry visible.
clay=special('_Clay',(.32,.34,.36),.15,.42)
sc.view_layers[0].material_override=clay
render(ROOT/'clay.png',1920,1080,24)
sc.view_layers[0].material_override=None
# Save and export the first complete asset before final evidence renders.
paint=MATS['Paint'];pnt=paint.node_tree;bs=pnt.nodes.get('Principled BSDF');tint=pnt.nodes['Faction paint factor'];base=next(n for n in pnt.nodes if n.type=='TEX_IMAGE' and n.image.name=='Paint_Base')
pnt.links.new(base.outputs[0],bs.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in sc.objects:
 if o.type in ['MESH','EMPTY']:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'interceptor-hardsf.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
# Make the runtime paint and glass contracts explicit in the GLB JSON.
p=ROOT/'interceptor-hardsf.glb';raw=p.read_bytes();jl,jt=struct.unpack_from('<II',raw,12);g=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
for m in g['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.55,.56,.55,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
j=json.dumps(g,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
p.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4E4F534A)+j+binary)
pnt.links.new(tint.outputs[0],bs.inputs['Base Color'])
# Manifest uses glTF coordinates, not Blender coordinates.
def gltfpos(v):return [round(v.x,5),round(v.z,5),round(-v.y,5)]
pts=[o.matrix_world@Vector(v) for o in OBJECTS.values() for v in o.bound_box]
lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
PAINTS=[('Naval grey',[.55,.56,.55]),('Gunmetal',[.16,.185,.21]),('Olive drab',[.22,.27,.16]),('Desert sand',[.64,.52,.34]),('Arctic white',[.86,.88,.87]),('Deep navy',[.055,.105,.19])]
manifest={'asset':'interceptor-hardsf','units':'metres','forward':'+Z','up':'+Y','left':'+X','origin':'hull centre of mass estimate','nodes':{o.name:{'position':gltfpos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'socket'} for o in sc.objects if o.type in ['MESH','EMPTY']},'axes':{'Canopy':[-1,0,0],'Radiator_L':[0,0,1],'Radiator_R':[0,0,-1]},'bounding_box':{'min':[round(lo.x,5),round(lo.z,5),round(-hi.y,5)],'max':[round(hi.x,5),round(hi.z,5),round(-lo.y,5)],'size':[6.5,4.2,16.0]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'triangles':triangles,'materials':list(MATS)[:-1],'bakes':BAKE_LOG,'moving_part_test':{'degrees':30,'origin_drift_metres':0}}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join(['Original 16.0 × 6.5 × 4.2 m hard-SF tandem interceptor; hinged canopy, deployed radiators, railgun, four RCS quads and fusion bell.',f'{triangles:,} triangles; fixed Hull and three moving mesh nodes; Y up, +Z nose; metre scale.', '8 materials: Paint, Alloy, Heatshield, Radiator, Cockpit, Glass, Engines, Lamps.', 'Cycles baked: shared tiling colour/normal 1024 px, ORM 512 px; optical/emissive 64 px; neutral Paint texture × faction factor.', 'CC0 photographic sources: Poly Haven metal_plate and metal_plate_02; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/metal_plate_02 .','Reference differences: radiator cassettes are deployed; original cheek armour, twin-rail muzzle and engine framing; no exhaust plume or markings.'])+'\n')
(EVID/'bake_provenance.json').write_text(json.dumps(BAKE_LOG,indent=2))
bpy.ops.wm.save_as_mainfile(filepath=str(EVID/'interceptor.blend'))
print('FIRST_GLB_COMPLETE',round(time.time()-START,1),p.stat().st_size,flush=True)
render(ROOT/'final.png')
camera((20,0,.25),(0,0,.25),18.5);render(ROOT/'side.png',1920,1080,24)
camera((12,19,10),(0,1,0),18.0);render(EVID/'rear.png',1280,800,24)
camera((7,-10,6),(0,-1.7,1.1),7.7);render(EVID/'cockpit.png',1280,900,24)
# Explicit 30 degree test of every moving assembly around its own pivot, then restore.
camera(HERO)
for n in ['Canopy','Radiator_L','Radiator_R']:
 o=OBJECTS[n];before=o.matrix_world.translation.copy();o.rotation_euler[0 if n=='Canopy' else 1]=math.radians(30 if n=='Radiator_R' else -30)
 bpy.context.view_layer.update();assert (o.matrix_world.translation-before).length<1e-6
render(EVID/'pivot_test.png',1280,720,24)
for n in ['Canopy','Radiator_L','Radiator_R']:OBJECTS[n].rotation_euler=(0,0,0)
# Six-paint proof in a real rendered 3x2 contact sheet, no typography on the asset.
frames=[]
for name,c in PAINTS:
 tint.inputs[2].default_value=(*c,1);render(EVID/f'paint_{name.replace(" ","_")}.png',640,360,16)
 im=bpy.data.images.load(str(EVID/f'paint_{name.replace(" ","_")}.png'),check_existing=False);a=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(a);frames.append(a.reshape(360,640,4).copy());bpy.data.images.remove(im)
grid=np.concatenate([np.concatenate(frames[3:],axis=1),np.concatenate(frames[:3],axis=1)],axis=0)
im=bpy.data.images.new('Six faction paints',1920,720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
tint.inputs[2].default_value=(.55,.56,.55,1)
print('BUILD_COMPLETE',round(time.time()-START,1),triangles,p.stat().st_size,flush=True)

hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "sed -n '1,230p' /home/claude-agent/.agents/skills/blender-game-assets/reference/critic-loop.md" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
<!-- Distilled from vercel-labs/vgpu .claude/skills/vgpu-agent-flow/references/asset-iteration.md and
.subharness/agents/asset-author.ts + asset-critic.ts at commit 68cb1d8d198c (MIT, Copyright (c) 2025 Vercel, Inc.),
rewritten for Forge lanes (forge-agent, Astra author, a second model family as critic). -->

# Author and critic loop for a Blender asset

Use this for any authored asset that will be judged by eye: a hero prop, a character, a building, an
environment. Skip it for a mechanical re-export.

## Seats

| Seat | Who | Owns |
| --- | --- | --- |
| Author | Astra (operator law: Blender work goes to Astra) | Geometry, authored materials, UVs, bakes, Blender renders, the export |
| Runtime | The root, or one worker when the shader work is real | Loading the export in the game's actual three.js path, captures, measured costs |
| Critic | A different model family from the author, reading the real images | Findings only. Never edits the asset |

The critic must open the actual capture files. A critic that cannot read images says so and returns
BLOCKED; it never writes a visual verdict from metrics or from the author's description.

## Budget

- Default: **3 revisions, at most 2 critic passes per revision.** The brief may set another cap.
- A cap is an execution limit, not acceptance. Cap reached with open visual findings = `REVISE`
  reported as such, never a `PASS`.
- No unattended improvement loop past the cap.

## Before the first render

Write in the task folder: scope, target cameras (gameplay view, hero, rear, closest required view),
output paths, budgets (triangles, exported vertices, texture bytes and GPU memory, draw calls) and
the revision cap. Budgets are task inputs; do not copy another asset's numbers.

Run the [preflight](preflight-and-recovery.md) once: Blender starts and runs a scene operation, a
tiny bake and export round-trips through the real loader, one real runtime frame is captured.

Integrate a cheap LOW blockout in the runtime at this stage. Scale, axis, camera and material
contract problems are found before an expensive bake, not after.

## Each revision keeps

- the source revision or parameters that rebuild the previous asset;
- real hero, rear and close-up captures, same camera, light, exposure and pose every time;
- triangles, exported vertices, primitives, material count, file bytes, texture GPU memory;
- runtime draw counters, kept apart from offline estimates;
- the critic's findings, the fixes chosen, and the next measured result.

## The critic pass

The critic receives the previous AND the current captures and metrics under matched cameras (or
an explicit "first revision, no baseline"), the reference image, and the brief. It returns:

- **at most five findings**, ordered by impact, each with a stable id, the evidence path (whole
  view plus crop or image coordinates), the visible symptom, a suggested correction and a
  **measurable acceptance condition**;
- regressions against the previous revision;
- resolved / remaining / regressed status of earlier findings on a follow-up pass;
- `PASS`, `REVISE` or `BLOCKED`, with the limits of its evidence.

It compares against the reference as well as the previous revision: clean topology and progress
since last time do not make the asset match the reference. It separates the observed defect from
the suspected cause. A small whole-image difference can hide severe local damage (thin trim,
stairs, silhouette edges): it inspects crops at each delivered LOD. Design ideas are suggestions,
not new acceptance requirements.

When the cause could be the asset or the renderer, it asks for the diagnostic captures in
[runtime parity](runtime-parity-and-diagnostics.md) (maps off, AO neutral, lightmap off) rather
than guessing.

## Rules that stop false passes

- A generated concept image is never proof of a working model. Label concepts as concepts; accept
  only on real renders of the real export.
- Requested bakes need bake provenance (what was baked, from what, at what settings) and matched
  runtime captures with each contribution switched off in turn. Vertex tint and runtime shadows
  are not substitutes for a requested AO or lightmap bake.
- An atlas repair is checked on the full matched views, not only on the crop that was broken: a new
  layout can fix one edge and turn another face black.
- One writer per handoff file. A reply never gets appended to a request file another agent will
  regenerate. Superseded manifests and failed captures stay on disk.
- The author hands the root a compact result: verdict, the few decisive images, the metrics table.
  Full capture sets stay on disk; the root does not load every image.
- A user's rejection or a changed brief reopens any earlier `PASS`.

## Lessons

When a revision teaches something that will recur, write it as: evidence path, symptom, cause,
intervention, matched before/after result, scope. The root checks it and makes a narrow edit to this
skill. Reject task-specific style preferences dressed up as general rules.

hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/textures/Paint_Base.png /tmp/interceptor-runtime/Paint_Base.png' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 892ms:
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'command -v geminix' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
/home/claude-agent/bin/geminix

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/textures/metal_plate_diff_1k.jpg /tmp/interceptor-runtime/metal.jpg' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
exec
/bin/bash -lc "timeout 220 geminix 'Read-only visual critique. Use image tools to actually open /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg and /tmp/interceptor-final-r2.png plus /tmp/interceptor-runtime/side.png and /tmp/interceptor-runtime/cockpit.png. Compare also /tmp/interceptor-final-r1.png. This is a 16m hard-SF military game asset with 6.5m deployed radiators; budget 90k tris, 12MB. Default Paint factor must remain .55,.56,.55 light grey. Give at most five specific findings and visual acceptance conditions, identify resolved or remaining earlier issues. Current author already plans to reduce canopy/spine height to align with engine rim and tanks, close small triangular gap at windshield foot, strengthen neutral baked wear. Do not edit files or delegate. Return PASS or REVISE from images.' > /tmp/interceptor-critic-r2.txt" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 810ms:
hook: PreToolUse
hook: PreToolUse Completed
codex
The side view confirms the canopy sits too high relative to the engine and tanks. I’m lowering it to match the sheet’s profile, closing the windscreen seam, and adding more visible wear to the neutral paint texture so the hull still recolours cleanly.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 - <<'PY'
import json
p=json.load(open('/tmp/interceptor-polyhaven-catalog.json'))
for k,v in p.items():
 if any(t in k for t in ['metal','painted']): print(k,v.get('name'))
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
black_painted_planks Black Painted Planks
blue_metal_plate Blue Metal Plate
blue_painted_planks Blue Painted Planks
box_profile_metal_sheet Box Profile Metal Sheet
concrete_floor_painted Concrete Floor Painted
distressed_painted_planks Distressed Painted Planks
green_metal_rust Green Metal Rust
metal_grate_rusty Metal Grate Rusty
metal_plate Metal Plate
metal_plate_02 Metal Plate 02
painted_brick Painted Brick
painted_concrete Painted Concrete
painted_concrete_02 Painted Concrete 02
painted_metal_shutter Painted Metal Shutter
painted_plaster_wall Painted Plaster Wall
painted_worn_brick Painted Worn Brick
peeling_painted_wall Peeling Painted Wall
rusty_metal Rusty Metal
rusty_metal_02 Rusty Metal 02
rusty_metal_03 Rusty Metal 03
rusty_metal_04 Rusty Metal 04
rusty_metal_05 Rusty Metal 05
rusty_metal_grid Rusty Metal Grid
rusty_metal_sheet Rusty Metal Sheet
rusty_metal_shutter Rusty Metal Shutter
rusty_painted_metal Rusty Painted Metal

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/textures/metal_plate_02_diff_1k.jpg /tmp/interceptor-runtime/metal02.jpg' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 920ms:
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cp build_interceptor-hardsf.py /tmp/interceptor-build-r3-before.py
cat /tmp/interceptor-critic-r2.txt' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
**Verdict: REVISE**

### Resolved vs. Remaining Issues (R1 to R2)
* **Resolved in R2 ([interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) vs. [interceptor-final-r2.png](file:///tmp/interceptor-final-r2.png)):**
  * **Forward side intake opened:** R1 had a flat, grated surface box; R2 cuts a deep recessed intake tunnel closer to [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg).
  * **Canopy sill sealed:** R1 had a large open gap along the lower side edge of the glass frame; R2 adds a stepped sill plate that closes the side seam.
  * **Propellant tank domes reshaped:** R1 had pointed, pinched front tank caps with an exposed mounting tab; R2 uses fuller hemispherical domes.
  * **Panel seams and fasteners added:** Forward fuselage plates and rivets are much clearer in R2.
* **Remaining from R1 (including author's planned fixes not yet in R2):**
  * **Triangular hole at windshield foot** is still open in [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png) (author plans to close).
  * **Towering canopy and dorsal spine** still sit way above the propellant tanks and engine bell rim in [side.png](file:///tmp/interceptor-runtime/side.png) (author plans to lower).
  * **Clean, toy-like hull shading** lacks surface breakup and edge wear while keeping the `.55, .56, .55` light grey paint factor (author plans to strengthen neutral baked wear).

---

### Top 5 Specific Findings & Visual Acceptance Conditions

1. **Overall Length-to-Height Proportions & Nose Taper ([side.png](file:///tmp/interceptor-runtime/side.png) vs. [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg))**
   * **Finding:** Even after lowering the canopy and spine, the hull body is too stubby and thick vertically, and the nose ends in a tall, blunt octagonal block instead of a long, low, needle-like wedge. The main engine bell is also oversized vertically (taller than the entire mid-fuselage).
   * **Acceptance Condition:** In side view, the fuselage length-to-height ratio matches [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg) (sleek 16m:4.2m profile). The nose tapers continuously into a sharp, low-profile chisel above the railgun, and the engine nozzle outer rim aligns with the top and bottom tank tangent lines.

2. **Canopy Frame Geometry & Glazing Continuity ([cockpit.png](file:///tmp/interceptor-runtime/cockpit.png))**
   * **Finding:** The canopy frame is built from disconnected cylinder bars ("pipe-frame" look) that protrude past the glass, with visible ball
 succeeded in 45671ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
apply patch
patch: completed
/home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..8c68258b17cea3a3c0a280327bffd849b8590f03
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -136,8 +136,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +185,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +229,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -248,7 +248,7 @@
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -320,7 +320,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,7 +328,7 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
@@ -346,7 +346,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.36,.43,.11),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +366,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +386,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +402,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.042,'Alloy',12,'Canopy')
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.043,'Alloy',12,'Canopy')
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +440,16 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint',bevel=.025)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +479,12 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+beacon=sphere('Dorsal beacon',(0,2.24,1.64),(.09,.15,.06),'Lamps',segs=16,rings=8)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +502,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..8c68258b17cea3a3c0a280327bffd849b8590f03
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -136,8 +136,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +185,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +229,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -248,7 +248,7 @@
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -320,7 +320,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,7 +328,7 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
@@ -346,7 +346,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.36,.43,.11),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +366,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +386,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +402,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.042,'Alloy',12,'Canopy')
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.043,'Alloy',12,'Canopy')
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +440,16 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint',bevel=.025)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +479,12 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+beacon=sphere('Dorsal beacon',(0,2.24,1.64),(.09,.15,.06),'Lamps',segs=16,rings=8)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +502,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cat /tmp/interceptor-critic-r2.txt' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
**Verdict: REVISE**

### Resolved vs. Remaining Issues (R1 to R2)
* **Resolved in R2 ([interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png) vs. [interceptor-final-r2.png](file:///tmp/interceptor-final-r2.png)):**
  * **Forward side intake opened:** R1 had a flat, grated surface box; R2 cuts a deep recessed intake tunnel closer to [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg).
  * **Canopy sill sealed:** R1 had a large open gap along the lower side edge of the glass frame; R2 adds a stepped sill plate that closes the side seam.
  * **Propellant tank domes reshaped:** R1 had pointed, pinched front tank caps with an exposed mounting tab; R2 uses fuller hemispherical domes.
  * **Panel seams and fasteners added:** Forward fuselage plates and rivets are much clearer in R2.
* **Remaining from R1 (including author's planned fixes not yet in R2):**
  * **Triangular hole at windshield foot** is still open in [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png) (author plans to close).
  * **Towering canopy and dorsal spine** still sit way above the propellant tanks and engine bell rim in [side.png](file:///tmp/interceptor-runtime/side.png) (author plans to lower).
  * **Clean, toy-like hull shading** lacks surface breakup and edge wear while keeping the `.55, .56, .55` light grey paint factor (author plans to strengthen neutral baked wear).

---

### Top 5 Specific Findings & Visual Acceptance Conditions

1. **Overall Length-to-Height Proportions & Nose Taper ([side.png](file:///tmp/interceptor-runtime/side.png) vs. [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg))**
   * **Finding:** Even after lowering the canopy and spine, the hull body is too stubby and thick vertically, and the nose ends in a tall, blunt octagonal block instead of a long, low, needle-like wedge. The main engine bell is also oversized vertically (taller than the entire mid-fuselage).
   * **Acceptance Condition:** In side view, the fuselage length-to-height ratio matches [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg) (sleek 16m:4.2m profile). The nose tapers continuously into a sharp, low-profile chisel above the railgun, and the engine nozzle outer rim aligns with the top and bottom tank tangent lines.

2. **Canopy Frame Geometry & Glazing Continuity ([cockpit.png](file:///tmp/interceptor-runtime/cockpit.png))**
   * **Finding:** The canopy frame is built from disconnected cylinder bars ("pipe-frame" look) that protrude past the glass, with visible ball-joint overlaps at vertices, an open triangular hole at the front foot, and a flat rectangular sill slab tacked onto the hull side.
   * **Acceptance Condition:** Canopy struts are flush, flat-faced armored mullions integrated smoothly into the surrounding hull skin; the front triangular gap is sealed flush with the nose deck; the stepped slab under the sill is faired smoothly into the chine.

3. **Forward Side Intake Cowl Shading & Shape ([cockpit.png](file:///tmp/interceptor-runtime/cockpit.png) & [side.png](file:///tmp/interceptor-runtime/side.png))**
   * **Finding:** The intake cutout has sharp vertical/horizontal shading pinch artifacts (normal smoothing tearing at the upper and lower corners of the lip) and a paper-thin outer lip, rather than the thick, chamfered trapezoidal intake scoop in [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg).
   * **Acceptance Condition:** Zero normal-shading creases or dark diagonal triangles around the intake mouth; the intake lip has thick, beveled structural walls with clean hard-edge/weighted-normal transitions.

4. **Nose Railgun Proportion, Mounting, and Barrel Detail ([interceptor-final-r2.png](file:///tmp/interceptor-final-r2.png) & [side.png](file:///tmp/interceptor-runtime/side.png))**
   * **Finding:** The chin railgun is too short, thin, and boxy, sticking out of the nose tip like a small peg rather than slung under a long ventral fairing with heavy twin rails and perforated barrel shrouds.
   * **Acceptance Condition:** The ventral gun pod starts well back under the forward fuselage chine and extends past the nose tip with clear upper/lower rail separation, barrel vents, and a solid armoured housing matching the side profile in [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg).

5. **Dorsal Spine Greebles & Propellant Tank Integration ([interceptor-final-r2.png](file:///tmp/interceptor-final-r2.png) & [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png))**
   * **Finding:** The glowing orange egg-shaped blob on the spine and the two floating saucer-like discs look like untextured primitives, and the propellant tanks sit on top of the hull like bare shiny cylinders rather than recessed into sculpted fairings with heat-scorched domes.
   * **Acceptance Condition:** Remove or replace the orange egg primitive with flush hard-SF access hatches matching [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg); seat the propellant tanks into contoured hull cradles with darker heat-scorched metallic wear on the end domes and collar rings, while keeping the main hull paint at `.55, .56, .55` light grey with stronger neutral baked AO/dirt/edge wear.

---

0. **Blocked on you**: nothing.
1. **What you changed**: Nothing; this was a read-only visual inspection.
2. **How you know it works**: Inspected [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg), [interceptor-final-r1.png](file:///tmp/interceptor-final-r1.png), [interceptor-final-r2.png](file:///tmp/interceptor-final-r2.png), [side.png](file:///tmp/interceptor-runtime/side.png), and [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png) directly with the image viewer tool.
3. **What would break if you were wrong**: Passing the model now would ship an interceptor with visible mesh gaps at the windshield base, shading artifacts around the intake, pipe-frame canopy bars, and stubby proportions that miss the concept sheet.
4. **What you did NOT do**: Did not inspect the `.glb` geometry, triangle count, or file size directly since this was an image-only critique.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
apply patch
patch: completed
/home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..a0b97c22ff60089c5e06cc50c647c9066920aa6d
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -320,7 +329,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,7 +337,7 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
@@ -346,7 +355,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.36,.43,.11),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +375,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +395,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +411,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +449,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +490,12 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+beacon=sphere('Dorsal beacon',(0,2.24,1.64),(.09,.15,.06),'Lamps',segs=16,rings=8)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +513,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +537,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'python3 -m py_compile build_interceptor-hardsf.py
scp build_interceptor-hardsf.py gex44:/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 1105ms:
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..a0b97c22ff60089c5e06cc50c647c9066920aa6d
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -320,7 +329,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,7 +337,7 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
@@ -346,7 +355,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.36,.43,.11),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +375,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +395,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +411,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +449,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +490,12 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+beacon=sphere('Dorsal beacon',(0,2.24,1.64),(.09,.15,.06),'Lamps',segs=16,rings=8)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +513,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +537,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
apply patch
patch: completed
/home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'python3 -m py_compile build_interceptor-hardsf.py
scp build_interceptor-hardsf.py gex44:/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 828ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'nohup /root/blender-spike/blender-5.2.0-linux-x64/blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py > /root/fal-author/interceptor-hardsf-v1/evidence/build.log 2>&1 < /dev/null & echo "'$!'"'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 507ms:
3904414

hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cat /tmp/interceptor-runtime/index.html
cat /tmp/interceptor-runtime/capture.mjs' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{margin:0;background:#050a11;}canvas{display:block}</style><script type="importmap">{"imports":{"three":"/node_modules/three/build/three.module.js","three/addons/":"/node_modules/three/examples/jsm/"}}</script></head><body><script type="module">
import * as THREE from 'three';import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js';
const renderer=new THREE.WebGLRenderer({antialias:true,preserveDrawingBuffer:true});renderer.setSize(1600,900);renderer.setPixelRatio(1);renderer.toneMapping=THREE.AgXToneMapping;renderer.toneMappingExposure=1;document.body.appendChild(renderer.domElement);
const scene=new THREE.Scene();scene.background=new THREE.Color('#080e18');const env=new THREE.PMREMGenerator(renderer);scene.environment=env.fromScene(new RoomEnvironment(),.05).texture;
const camera=new THREE.OrthographicCamera(-9.5,9.5,9.5*9/16,-9.5*9/16,.1,100);camera.position.set(15,14,22);camera.lookAt(0,.1,0);
const key=new THREE.DirectionalLight(0xfff5eb,3);key.position.set(3,12,8);scene.add(key);const fill=new THREE.DirectionalLight(0xaacbff,1.4);fill.position.set(-8,6,-1);scene.add(fill);
const gltf=await new GLTFLoader().loadAsync('/interceptor-hardsf.glb');scene.add(gltf.scene);gltf.scene.updateMatrixWorld(true);const bounds=new THREE.Box3().setFromObject(gltf.scene);const materials=new Map();let meshes=0;gltf.scene.traverse(o=>{if(o.isMesh){meshes++;materials.set(o.material.name,o.material)}});
for(let i=0;i<4;i++)renderer.render(scene,camera);
window.result={bounds:{min:bounds.min.toArray(),max:bounds.max.toArray(),size:bounds.getSize(new THREE.Vector3()).toArray()},drawCalls:renderer.info.render.calls,triangles:renderer.info.render.triangles,meshes,materials:[...materials.keys()],webgl:renderer.getContext().getParameter(renderer.getContext().VERSION),paintFactor:materials.get('Paint').color.toArray()};
window.setPaint=(rgb)=>{materials.get('Paint').color.fromArray(rgb);renderer.render(scene,camera)};window.done=true;
</script></body></html>
import {chromium} from 'playwright';import fs from 'fs';
const browser=await chromium.launch({headless:true,executablePath:'/home/claude-agent/.cache/ms-playwright/chromium-1223/chrome-linux/chrome',args:['--no-sandbox','--use-angle=swiftshader','--enable-unsafe-swiftshader']});const page=await browser.newPage({viewport:{width:1600,height:900}});let errors=[];page.on('pageerror',e=>errors.push(String(e)));await page.goto('http://127.0.0.1:8347/');await page.waitForFunction(()=>window.done,{timeout:60000});await page.screenshot({path:'/tmp/interceptor-runtime/runtime.png'});fs.writeFileSync('/tmp/interceptor-runtime/runtime.json',JSON.stringify({...await page.evaluate(()=>window.result),errors},null,2));await browser.close();

exec
/bin/bash -lc 'df -h /
ls -lh' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
exec
/bin/bash -lc 'cat /home/claude-agent/.agents/skills/blender-game-assets/scripts/audit_glb.py | head -n 100' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1       150G  137G  7.4G  95% /
total 1.1M
-rw-rw-r-- 1 claude-agent claude-agent 7.1K Oct 16 18:39 PROMPT.txt
drwxrwxr-x 2 claude-agent claude-agent 4.0K Oct 16 19:00 __pycache__
-rw-rw-r-- 1 claude-agent claude-agent 374K Oct 16 19:00 astra.log
-rw-rw-r-- 1 claude-agent claude-agent  42K Oct 16 19:00 build_interceptor-hardsf.py
-rw-rw-r-- 1 claude-agent claude-agent 4.9K Oct 16 18:37 job.json
-rw-rw-r-- 1 claude-agent claude-agent 644K Oct 16 18:34 sheet.jpg
-rw-rw-r-- 1 claude-agent claude-agent 1.1K Oct 16 18:37 sheet_prompt.txt
-rw-rw-r-- 1 claude-agent claude-agent   21 Oct 16 18:39 started.txt

 succeeded in 0ms:
"""Read-only audit of uncompressed GLB triangle meshes; requires numpy.

Report mode exits 0 even with findings. --strict exits 1 for findings.
Invalid or unsupported input exits 2. This is not a complete glTF validator.
"""
import argparse
import hashlib
import json
from pathlib import Path
import struct
import sys

import numpy as np


def audit(path):
    data = Path(path).read_bytes()
    if len(data) < 20 or struct.unpack_from('<4sII', data) != (b'glTF', 2, len(data)):
        raise ValueError('Invalid GLB v2 header/length')
    chunks = {}
    offset = 12
    while offset < len(data):
        if offset + 8 > len(data):
            raise ValueError('Truncated chunk header')
        length, kind = struct.unpack_from('<II', data, offset)
        offset += 8
        if length % 4 or offset + length > len(data) or kind in chunks:
            raise ValueError('Invalid, duplicate or truncated GLB chunk')
        chunks[kind] = data[offset:offset + length]
        offset += length
    g = json.loads(chunks[0x4E4F534A])
    binary = chunks.get(0x004E4942, b'')
    compressed = {'KHR_draco_mesh_compression', 'EXT_meshopt_compression'}
    if compressed.intersection(g.get('extensionsUsed', []) + g.get('extensionsRequired', [])):
        raise ValueError('Compressed geometry: audit the uncompressed export')
    buffers = g.get('buffers', [])
    if len(buffers) != 1 or 'uri' in buffers[0] or buffers[0]['byteLength'] > len(binary):
        raise ValueError('Expected one embedded geometry buffer')
    dtypes = {5120: 'i1', 5121: 'u1', 5122: '<i2', 5123: '<u2', 5125: '<u4', 5126: '<f4'}
    widths = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4}

    def accessor(index):
        q = g['accessors'][index]
        if 'sparse' in q or 'bufferView' not in q:
            raise ValueError('Sparse or implicit accessor unsupported')
        v = g['bufferViews'][q['bufferView']]
        if v.get('buffer', 0) != 0 or 'EXT_meshopt_compression' in v.get('extensions', {}):
            raise ValueError('Unsupported buffer view')
        dtype = np.dtype(dtypes[q['componentType']])
        width = widths[q['type']]
        stride = v.get('byteStride', dtype.itemsize * width)
        count = q['count']
        start = q.get('byteOffset', 0)
        end = start + (count - 1) * stride + width * dtype.itemsize if count else start
        base = v.get('byteOffset', 0)
        if count < 0 or start < 0 or base < 0 or stride < dtype.itemsize * width or end > v['byteLength'] or base + v['byteLength'] > buffers[0]['byteLength']:
            raise ValueError('Accessor exceeds declared buffer bounds')
        return np.ndarray((count, width), dtype=dtype, buffer=binary,
                          offset=base + start, strides=(stride, dtype.itemsize))

    rows = []
    for mi, mesh in enumerate(g.get('meshes', [])):
        for pi, primitive in enumerate(mesh['primitives']):
            if primitive.get('mode', 4) != 4 or primitive.get('targets') or 'KHR_draco_mesh_compression' in primitive.get('extensions', {}):
                raise ValueError('Only uncompressed TRIANGLES without morph targets supported')
            attrs = {name: accessor(i) for name, i in primitive['attributes'].items()}
            pos = attrs['POSITION']
            if pos.shape[1] != 3 or g['accessors'][primitive['attributes']['POSITION']].get('normalized'):
                raise ValueError('Expected non-normalized VEC3 positions')
            idx = np.arange(len(pos))
            if 'indices' in primitive:
                q = g['accessors'][primitive['indices']]
                if q['type'] != 'SCALAR' or q['componentType'] not in (5121, 5123, 5125):
                    raise ValueError('Invalid index accessor')
                idx = accessor(primitive['indices']).ravel()
            if len(idx) % 3 or (len(idx) and int(idx.max()) >= len(pos)):
                raise ValueError('Invalid triangle indices')
            t = pos[idx.reshape(-1, 3)].astype(np.float64)
            area2 = np.linalg.norm(np.cross(t[:, 1] - t[:, 0], t[:, 2] - t[:, 0]), axis=1)
            rows.append({'mesh': mesh.get('name', str(mi)), 'primitive': pi,
                         'triangles': len(idx) // 3,
                         'zero_area_triangles': int((area2 < 1e-12).sum()),
                         'nonfinite_attribute_components': sum(int((~np.isfinite(a)).sum()) for a in attrs.values())})
    if not rows:
        raise ValueError('No triangle primitives to audit')
    # Rig report and duplicate-primitive detection (2026-09-21, after the MengTo rejection gates):
    # duplicate = two primitives whose POSITION and index buffers hash identically (an action file
    # carrying a second render mesh); the rig report lists skins, joint names and their parents.
    seen, duplicates = {}, []
    for mi, mesh in enumerate(g.get('meshes', [])):
        for pi, primitive in enumerate(mesh['primitives']):
            key = hashlib.sha1(accessor(primitive['attributes']['POSITION']).tobytes()
                               + (accessor(primitive['indices']).tobytes() if 'indices' in primitive else b'')).hexdigest()
            here = f"{mesh.get('name', str(mi))}[{pi}]"
            if key in seen:
                duplicates.append({'primitive': here, 'same_as': seen[key]})
            else:
                seen[key] = here
    nodes = g.get('nodes', [])
    parent_of = {}

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 8 /root/fal-author/interceptor-hardsf-v1/evidence/build.log'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 406ms:
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:197: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "cat > /tmp/interceptor-runtime/contract.py <<'PY'
import json,struct,io,sys,hashlib
from pathlib import Path
from PIL import Image
p=Path(sys.argv[1]);b=p.read_bytes();n=struct.unpack_from('<I',b,12)[0];g=json.loads(b[20:20+n]);binary=b[28+n:]
checks={};issues=[]
def check(n,ok,detail=None):
 checks[n]={'pass':bool(ok),'detail':detail}
 if not ok:issues.append(n)
mats={m['name']:m for m in g['materials']};nodes={n['name']:n for n in g['nodes']}
tris=sum(g['accessors'][q['indices']]['count']//3 for m in g['meshes'] for q in m['primitives'])
verts=sum(g['accessors'][q['attributes']['POSITION']]['count'] for m in g['meshes'] for q in m['primitives'])
check('triangle_budget',40000<=tris<=90000,tris);check('file_budget',len(b)<=12000000,len(b));check('material_budget',len(mats)<=9,list(mats));check('paint_factor',mats['Paint']['pbrMetallicRoughness']['baseColorFactor']==[.55,.56,.55,1]);check('glass_blend',mats['Glass'].get('alphaMode')=='BLEND');check('emissive_contract',all('emissiveFactor'in mats[k] for k in ['Engines','Lamps']));check('no_cameras_lights','cameras'not in g and 'KHR_lights_punctual'not in g.get('extensions',{}))
required=['Hull','Canopy','Radiator_L','Radiator_R','Seat_Pilot','Seat_Copilot','Camera_Chase','Light_Nav_L','Light_Nav_R','Light_Beacon','Muzzle_1']+[f'Thruster_{i}'for i in range(1,18)]+['Hardpoint_1','Hardpoint_2']
check('named_nodes',all(k in nodes for k in required),list(nodes));check('moving_identity_scale',all(nodes[k].get('scale',[1,1,1])==[1,1,1] for k in ['Canopy','Radiator_L','Radiator_R']))
imgs=[];gpu=0
for i,im in enumerate(g.get('images',[])):
 v=g['bufferViews'][im['bufferView']];a=v.get('byteOffset',0);x=Image.open(io.BytesIO(binary[a:a+v['byteLength']]));imgs.append({'name':im.get('name',str(i)),'size':x.size,'bytes':v['byteLength']});gpu+=x.width*x.height*4*4/3
check('embedded_images',all('uri'not in im for im in g['images']));check('image_limit',all(max(a['size'])<=1024 for a in imgs),imgs)
for name,m in mats.items():
 for k in ['metallicRoughnessTexture']:
  if k in m['pbrMetallicRoughness']:
   idx=m['pbrMetallicRoughness'][k]['index'];i=g['textures'][idx]['source'];check(name+'_ORM_limit',max(imgs[i]['size'])<=512)
 if 'occlusionTexture'in m:
  i=g['textures'][m['occlusionTexture']['index']]['source'];check(name+'_AO_limit',max(imgs[i]['size'])<=512)
check('mesh_nodes_only_expected',set(n['name']for n in g['nodes']if'mesh'in n)=={'Hull','Canopy','Radiator_L','Radiator_R'})
r={'file':p.name,'sha256':hashlib.sha256(b).hexdigest(),'verdict':'PASS'if not issues else'FAIL','triangles':tris,'exported_vertices':verts,'primitives':sum(len(m['primitives'])for m in g['meshes']),'image_gpu_mib_rgba8_with_mips':round(gpu/1024**2,2),'checks':checks,'issues':issues}
print(json.dumps(r,indent=2));sys.exit(bool(issues))
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

codex
The next build is baking and rendering now. It includes the lower canopy, flatter armour frames, a sealed forward sill, thicker cheek recess walls, and heat-darkened tank ends. The hinge test also keeps the canopy and radiators attached when each rotates 30 degrees.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 - <<'PY'
p='/tmp/interceptor-runtime/index.html'
s=open(p).read();s=s.replace('window.done=true;','''const originals=new Map([...materials.values()].map(m=>[m,{map:m.map,normalMap:m.normalMap,aoMap:m.aoMap,roughnessMap:m.roughnessMap,metalnessMap:m.metalnessMap}]));
window.setMode=(mode)=>{for(const [m,orig]of originals){Object.assign(m,orig);if(mode==='normal_off')m.normalMap=null;if(mode==='ao_off')m.aoMap=null;if(mode==='maps_off'){m.map=null;m.normalMap=null;m.aoMap=null;m.roughnessMap=null;m.metalnessMap=null;}m.needsUpdate=true;}renderer.render(scene,camera)};window.done=true;''');open(p,'w').write(s)
p='/tmp/interceptor-runtime/capture.mjs';s=open(p).read();s=s.replace('await browser.close();',\"\"\"for(const mode of ['normal_off','ao_off','maps_off']){await page.evaluate(m=>window.setMode(m),mode);await page.screenshot({path:'/tmp/interceptor-runtime/runtime_'+mode+'.png'});}await page.evaluate(()=>{window.setMode('default');window.setPaint([.055,.105,.19])});await page.screenshot({path:'/tmp/interceptor-runtime/runtime_navy.png'});await browser.close();\"\"\");open(p,'w').write(s)
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 14 /root/fal-author/interceptor-hardsf-v1/evidence/build.log; ps -eo pid,etime,pcpu,args | grep -F interceptor-hardsf-v1 | grep -v grep'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 510ms:
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Heatshield
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:197: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
3904414       01:02  0.0 python3 /opt/gpu-gate/gpu_gate.py --name blender --need-mib 6000 --priority batch -- /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
3904418       01:02  159 /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 12 /root/fal-author/interceptor-hardsf-v1/evidence/build.log'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 497ms:
Info: Baking map saved to internal image, save it externally or pack it
BAKED Heatshield
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:197: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha

hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 14 /root/fal-author/interceptor-hardsf-v1/evidence/build.log'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 515ms:
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Heatshield
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:197: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "du -sh /tmp/interceptor-runtime
sed -n '118,200p' /home/claude-agent/.agents/skills/blender-game-assets/scripts/audit_glb.py" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
59M	/tmp/interceptor-runtime
              'duplicate_primitives': duplicates,
              'skins': skins,
              'node_names': sorted(n for n in node_names if n),
              'note': 'Mesh-local counts, before node transforms/instances. Degenerate threshold: cross-product magnitude < 1e-12 in asset units squared. Not a source-topology or visual-quality verdict.'}
    for key in ['triangles', 'zero_area_triangles', 'nonfinite_attribute_components']:
        result[key] = sum(r[key] for r in rows)
    result['findings'] = [key for key in ['zero_area_triangles', 'nonfinite_attribute_components', 'external_uris', 'duplicate_primitives'] if result[key]]
    if any(sk['flat_pivot_list'] for sk in skins):
        result['findings'].append('flat_pivot_list_rig')
    return result


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('glb', type=Path)
    parser.add_argument('--strict', action='store_true')
    parser.add_argument('--rig', action='store_true', help='print the skin/joint report (always computed; this only keeps it in the output)')
    parser.add_argument('--require-sockets', default='', help='comma-separated node names that must exist (e.g. target,ground,vfx-hit,vfx-death)')
    args = parser.parse_args()
    try:
        result = audit(args.glb)
        required = [x.strip() for x in args.require_sockets.split(',') if x.strip()]
        missing = [x for x in required if x not in result['node_names']]
        if missing:
            result['missing_sockets'] = missing
            result['findings'].append('missing_sockets')
        if not args.rig:
            result.pop('skins', None); result.pop('node_names', None)
    except (ValueError, KeyError, IndexError, TypeError, OSError, struct.error) as exc:
        print(json.dumps({'status': 'unsupported_or_invalid', 'error': str(exc)}))
        return 2
    print(json.dumps(result, indent=2))
    return 1 if args.strict and result['findings'] else 0


if __name__ == '__main__':
    sys.exit(main())

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 18 /root/fal-author/interceptor-hardsf-v1/evidence/build.log; ps -eo pid,etime,pcpu,args | grep -F interceptor-hardsf-v1 | grep -v grep'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 601ms:
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Alloy
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Heatshield
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:197: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
3904414       01:53  0.0 python3 /opt/gpu-gate/gpu_gate.py --name blender --need-mib 6000 --priority batch -- /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
3904418       01:53  156 /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 20 /root/fal-author/interceptor-hardsf-v1/evidence/build.log'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 513ms:
21:02:46 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
21:02:46 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
21:02:46 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
21:02:46 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
21:02:46 | WARNING: More than one shader node tex image used for a texture. The resulting glTF sampler will behave like the first shader node tex image.
21:02:46 | INFO: Primitives created: 8
21:02:46 | INFO: Extracting primitive: Forward laminated glass end
21:02:46 | INFO: Primitives created: 3
21:02:46 | INFO: Extracting primitive: Radiator cassette
21:02:46 | INFO: Primitives created: 3
21:02:46 | INFO: Extracting primitive: Radiator cassette.005
21:02:46 | INFO: Primitives created: 3
21:02:46 | INFO: Finished glTF 2.0 export in 0.21162629127502441 s

Info: Saved as "interceptor.blend"
FIRST_GLB_COMPLETE 123.9 8416212
02:16.119  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/final.png'
02:27.371  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/side.png'
02:33.174  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/rear.png'
02:39.527  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/cockpit.png'

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/final.png /tmp/interceptor-final-r3.png
scp gex44:/root/fal-author/interceptor-hardsf-v1/side.png gex44:/root/fal-author/interceptor-hardsf-v1/evidence/cockpit.png /tmp/interceptor-runtime/' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 5000ms:
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..00a7e1f540c8200c93152ecfdceabe90b534e7fb
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,8 +142,8 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
@@ -185,7 +191,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +235,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +252,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -280,10 +289,22 @@
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +341,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +349,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +370,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +390,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.66
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +410,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +426,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +464,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +505,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +529,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.66));socket('Seat_Copilot',(0,-.65,.66));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +553,15 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

codex
The lower profile now follows the sheet much more closely, and the windscreen gap is closed. This render exposed two smaller defects: triangulated shading on the armour and a headrest touching the glass. I’m correcting those before checking the exported asset.

ERROR: Reconnecting... 2/5
ERROR: Reconnecting... 3/5
ERROR: Reconnecting... 4/5
ERROR: Reconnecting... 5/5
warning: Falling back from WebSockets to HTTPS transport. stream disconnected before completion: websocket closed by server before response.completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "cp build_interceptor-hardsf.py /tmp/interceptor-build-r3.py
ssh gex44 'tail -n 3 /root/fal-author/interceptor-hardsf-v1/evidence/build.log'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 706ms:
BUILD_COMPLETE 175.3 87996 8416212

Blender quit

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
apply patch
patch: completed
/home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..8cbee6a268e18d71f1e66a77c7100be54ae35e60
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,21 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'python3 -m py_compile build_interceptor-hardsf.py
scp build_interceptor-hardsf.py gex44:/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 994ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'nohup /root/blender-spike/blender-5.2.0-linux-x64/blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py > /root/fal-author/interceptor-hardsf-v1/evidence/build.log 2>&1 < /dev/null & echo "'$!'"'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 528ms:
3928806

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..8cbee6a268e18d71f1e66a77c7100be54ae35e60
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,21 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/interceptor-hardsf.glb /tmp/interceptor-runtime/interceptor-hardsf.glb
python3 /tmp/interceptor-runtime/contract.py /tmp/interceptor-runtime/interceptor-hardsf.glb > /tmp/interceptor-contract-r3.json
python3 /home/claude-agent/.agents/skills/blender-game-assets/scripts/audit_glb.py /tmp/interceptor-runtime/interceptor-hardsf.glb --strict > /tmp/interceptor-audit-r3.json' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 exited 1 in 1864ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 - <<'PY'
import json
for p in ['/tmp/interceptor-contract-r3.json','/tmp/interceptor-audit-r3.json']:
 d=json.load(open(p)); print(p,{k:v for k,v in d.items()if k in ['verdict','triangles','exported_vertices','primitives','image_gpu_mib_rgba8_with_mips','issues','findings','zero_area_triangles','nonfinite_attribute_components']})
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
/tmp/interceptor-contract-r3.json {'verdict': 'PASS', 'triangles': 87996, 'exported_vertices': 91505, 'primitives': 17, 'image_gpu_mib_rgba8_with_mips': 60.19, 'issues': []}
/tmp/interceptor-audit-r3.json {'primitives': [{'mesh': 'Forward laminated glass end', 'primitive': 0, 'triangles': 146, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Forward laminated glass end', 'primitive': 1, 'triangles': 1446, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Forward laminated glass end', 'primitive': 2, 'triangles': 2796, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Radiator cassette', 'primitive': 0, 'triangles': 432, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Radiator cassette', 'primitive': 1, 'triangles': 2740, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Radiator cassette', 'primitive': 2, 'triangles': 1600, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Radiator cassette.005', 'primitive': 0, 'triangles': 452, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Radiator cassette.005', 'primitive': 1, 'triangles': 2720, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Radiator cassette.005', 'primitive': 2, 'triangles': 1600, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Continuous pressure hull', 'primitive': 0, 'triangles': 6980, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Continuous pressure hull', 'primitive': 1, 'triangles': 40906, 'zero_area_triangles': 8, 'nonfinite_attribute_components': 0}, {'mesh': 'Continuous pressure hull', 'primitive': 2, 'triangles': 3556, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Continuous pressure hull', 'primitive': 3, 'triangles': 168, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Continuous pressure hull', 'primitive': 4, 'triangles': 1414, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Continuous pressure hull', 'primitive': 5, 'triangles': 19076, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Continuous pressure hull', 'primitive': 6, 'triangles': 516, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}, {'mesh': 'Continuous pressure hull', 'primitive': 7, 'triangles': 1448, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0}], 'triangles': 87996, 'zero_area_triangles': 8, 'nonfinite_attribute_components': 0, 'findings': ['zero_area_triangles']}

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..8cbee6a268e18d71f1e66a77c7100be54ae35e60
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,21 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'ps -eo pid,ppid,etime,args | grep -F interceptor-hardsf-v1 | grep -v grep'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 559ms:
3928806       1       00:53 python3 /opt/gpu-gate/gpu_gate.py --name blender --need-mib 6000 --priority batch -- /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
apply patch
patch: completed
/home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..14634426e2b49025f222ca142a8e94a256da811e
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,7 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
-print('GEOMETRY',triangles,'triangles',flush=True)
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+ print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.
 world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..14634426e2b49025f222ca142a8e94a256da811e
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,7 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
-print('GEOMETRY',triangles,'triangles',flush=True)
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+ print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.
 world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "sed -n '566,603p' build_interceptor-hardsf.py
ssh gex44 'tail -n 4 /root/fal-author/interceptor-hardsf-v1/evidence/build.log; kill -TERM 3928806'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 473ms:
bpy.context.view_layer.update()
# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
# use area-weighted normals; smooth round components retain their continuous highlights.
for o in OBJECTS.values():
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
 if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
 bm.to_mesh(o.data);bm.free()
 for p in o.data.polygons:p.use_smooth=True
 o.data.set_sharp_from_angle(angle=math.radians(35))
 active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
 print('GEOMETRY',triangles,'triangles',flush=True)

# Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.
world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world
nt=world.node_tree;nt.nodes.clear();N=nt.nodes;L=nt.links
out=N.new('ShaderNodeOutputWorld');bg=N.new('ShaderNodeBackground');bg.inputs['Strength'].default_value=.24
coord=N.new('ShaderNodeTexCoord');sep=N.new('ShaderNodeSeparateXYZ');L.new(coord.outputs['Normal'],sep.inputs[0]);r=N.new('ShaderNodeValToRGB');r.color_ramp.elements[0].position=0;r.color_ramp.elements[0].color=(.003,.006,.014,1);r.color_ramp.elements[1].position=1;r.color_ramp.elements[1].color=(.025,.043,.075,1);L.new(sep.outputs['Z'],r.inputs[0]);L.new(r.outputs[0],bg.inputs[0]);L.new(bg.outputs[0],out.inputs[0])
def area(name,loc,power,color,size,target=(0,0,0)):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.color=color;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler()
area('Upper front white key',(3,-8,12),2500,(1,.97,.92),7)
area('Cool soft fill',(-8,-1,6),1800,(.64,.78,1),8)
area('Engine rim',(1,9,8),2900,(.72,.83,1),6)
area('Soft belly fill',(3,-2,-6),700,(.66,.73,1),6)
sun=bpy.data.lights.new('Sunlight','SUN');sun.energy=1.2;sun.angle=.08;sun.color=(1,.98,.95);so=bpy.data.objects.new('Sunlight',sun);sc.collection.objects.link(so);so.rotation_euler=(.4,-.5,-.4)
camd=bpy.data.cameras.new('Evidence camera');cam=bpy.data.objects.new('Evidence camera',camd);sc.collection.objects.link(cam);sc.camera=cam
HERO=(15,-22,14)
def camera(loc,target=(0,0,.1),ortho=19.0):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=ortho
camera(HERO)
def render(path,w=1920,h=1080,samples=48):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.cycles.samples=samples;sc.render.filepath=str(path);bpy.ops.render.render(write_still=True)
# First a clay gate with all silhouette geometry visible.
clay=special('_Clay',(.32,.34,.36),.15,.42)
sc.view_layers[0].material_override=clay
render(ROOT/'clay.png',1920,1080,24)
sc.view_layers[0].material_override=None
# Save and export the first complete asset before final evidence renders.

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..14634426e2b49025f222ca142a8e94a256da811e
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,7 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
-print('GEOMETRY',triangles,'triangles',flush=True)
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+ print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.
 world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
apply patch
patch: completed
/home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 -m py_compile build_interceptor-hardsf.py
scp build_interceptor-hardsf.py gex44:/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
ssh gex44 'ps -eo pid,etime,args | grep -F interceptor-hardsf-v1 | grep -v grep'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 exited 1 in 1496ms:
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'nohup /root/blender-spike/blender-5.2.0-linux-x64/blender -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py > /root/fal-author/interceptor-hardsf-v1/evidence/build.log 2>&1 < /dev/null & echo "'$!'"'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 565ms:
3936768

hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 6 /root/fal-author/interceptor-hardsf-v1/evidence/build.log; ps -eo pid,etime,pcpu,args | grep -F interceptor-hardsf-v1 | grep -v grep'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 618ms:
Info: Baking map saved to internal image, save it externally or pack it
BAKED Heatshield
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
3936768       00:33  0.0 python3 /opt/gpu-gate/gpu_gate.py --name blender --need-mib 6000 --priority batch -- /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
3938587       00:08  118 /root/blender-spike/blender-5.2.0-linux-x64/blender.real -b --python-exit-code 1 --python /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'node /tmp/interceptor-runtime/capture.mjs' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

codex
The export checks pass for size, materials, textures, sockets, and paint factors. The geometry audit found eight tiny collapsed triangles left by mesh reduction; the final build now removes those and rebuilds the surface normals afterward.

hook: PreToolUse
hook: PreToolUse Completed
2026-10-16T19:08:13.247221Z ERROR codex_core::tools::router: error=unable to locate image at `/tmp/interceptor-runtime/runtime_normal_off.png`: No such file or directory (os error 2)
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 10 /root/fal-author/interceptor-hardsf-v1/evidence/build.log'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 507ms:
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Radiator
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:198: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha

 succeeded in 43885ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'ls -l /tmp/interceptor-runtime/runtime*.png' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
-rw-rw-r-- 1 claude-agent claude-agent 449179 Oct 16 19:08 /tmp/interceptor-runtime/runtime.png
-rw-rw-r-- 1 claude-agent claude-agent 447671 Oct 16 19:08 /tmp/interceptor-runtime/runtime_ao_off.png
-rw-rw-r-- 1 claude-agent claude-agent 250308 Oct 16 19:08 /tmp/interceptor-runtime/runtime_maps_off.png
-rw-rw-r-- 1 claude-agent claude-agent 451767 Oct 16 19:08 /tmp/interceptor-runtime/runtime_navy.png
-rw-rw-r-- 1 claude-agent claude-agent 442566 Oct 16 19:08 /tmp/interceptor-runtime/runtime_normal_off.png

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cat /tmp/interceptor-runtime/runtime.json' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
{
  "bounds": {
    "min": [
      -3.2505887746810913,
      -2.031895160675049,
      -7.9951887130737305
    ],
    "max": [
      3.2494107484817505,
      2.168104648590088,
      8.004810333251953
    ],
    "size": [
      6.499999523162842,
      4.199999809265137,
      15.999999046325684
    ]
  },
  "drawCalls": 19,
  "triangles": 88310,
  "meshes": 17,
  "materials": [
    "Paint",
    "Alloy",
    "Cockpit",
    "Glass",
    "Radiator",
    "Heatshield",
    "Lamps",
    "Engines"
  ],
  "webgl": "WebGL 2.0 (OpenGL ES 3.0 Chromium)",
  "paintFactor": [
    0.55,
    0.56,
    0.55
  ],
  "errors": []
}
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 18 /root/fal-author/interceptor-hardsf-v1/evidence/build.log'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 497ms:
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
BAKED Cockpit
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:198: DeprecationWarning: 'Material.use_nodes' is expected to be removed in Blender 6.0
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
Info: Baking map saved to internal image, save it externally or pack it
GEOMETRY 87989 triangles
/root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py:581: DeprecationWarning: 'World.use_nodes' is expected to be removed in Blender 6.0
  world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

codex
The GLB loads in Three.js without browser errors at the required dimensions. It uses 19 draw calls, and changing the paint factor leaves the tanks, glazing, weapons, and lights independent. I’m checking the final render set and the six-colour proof next.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 10 /root/fal-author/interceptor-hardsf-v1/evidence/build.log'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 511ms:
21:09:48 | INFO: Extracting primitive: Forward laminated glass end
21:09:48 | INFO: Primitives created: 3
21:09:48 | INFO: Extracting primitive: Radiator cassette
21:09:48 | INFO: Primitives created: 3
21:09:48 | INFO: Extracting primitive: Radiator cassette.005
21:09:48 | INFO: Primitives created: 3
21:09:48 | INFO: Finished glTF 2.0 export in 0.2317047119140625 s

Info: Saved as "interceptor.blend"
FIRST_GLB_COMPLETE 118.7 8782724

hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/interceptor-hardsf.glb /tmp/interceptor-runtime/interceptor-hardsf.glb
python3 /tmp/interceptor-runtime/contract.py /tmp/interceptor-runtime/interceptor-hardsf.glb > /tmp/interceptor-contract-final.json
python3 /home/claude-agent/.agents/skills/blender-game-assets/scripts/audit_glb.py /tmp/interceptor-runtime/interceptor-hardsf.glb --strict > /tmp/interceptor-audit-final.json' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 1878ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 - <<'PY'
import json
for p in ['/tmp/interceptor-contract-final.json','/tmp/interceptor-audit-final.json']:
 d=json.load(open(p)); print(p,{k:v for k,v in d.items()if k in ['verdict','triangles','exported_vertices','image_gpu_mib_rgba8_with_mips','issues','findings','zero_area_triangles','nonfinite_attribute_components']})
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
/tmp/interceptor-contract-final.json {'verdict': 'PASS', 'triangles': 87989, 'exported_vertices': 90269, 'image_gpu_mib_rgba8_with_mips': 60.19, 'issues': []}
/tmp/interceptor-audit-final.json {'triangles': 87989, 'zero_area_triangles': 0, 'nonfinite_attribute_components': 0, 'findings': []}

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'node /tmp/interceptor-runtime/capture.mjs' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/final.png /tmp/interceptor-final-r4.png
scp gex44:/root/fal-author/interceptor-hardsf-v1/side.png /tmp/interceptor-runtime/side.png' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 2075ms:
hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ssh gex44 'tail -n 12 /root/fal-author/interceptor-hardsf-v1/evidence/build.log'" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 485ms:
02:23.160  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/rear.png'
02:30.150  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/cockpit.png'
02:37.534  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/pivot_test.png'
02:39.145  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Naval_grey.png'
02:40.903  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Gunmetal.png'
02:44.189  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Olive_drab.png'
02:47.145  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Desert_sand.png'
02:49.170  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Arctic_white.png'
02:51.185  render           | Saved: '/root/fal-author/interceptor-hardsf-v1/evidence/paint_Deep_navy.png'
BUILD_COMPLETE 170.8 87989 8782724

Blender quit

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/side.png gex44:/root/fal-author/interceptor-hardsf-v1/clay.png gex44:/root/fal-author/interceptor-hardsf-v1/evidence/cockpit.png gex44:/root/fal-author/interceptor-hardsf-v1/evidence/rear.png /tmp/interceptor-runtime/' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 4063ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "timeout 220 geminix 'Independent image-only final review. Open the actual images: /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg, /tmp/interceptor-final-r4.png, /tmp/interceptor-runtime/side.png, /tmp/interceptor-runtime/cockpit.png, /tmp/interceptor-runtime/rear.png. Compare previous /tmp/interceptor-final-r3.png. A 16m hard-SF interceptor, mandatory light-grey Paint factor .55,.56,.55, radiators deployed, original detailed nose/engine allowed. Current audit is 87989 triangles, 8 materials, 8.78MB, 30 semantic nodes, 1024px colour/normal 512 ORM. Review actual visible construction and silhouette, previous triangle shading, glazing clipping and seam gaps, photographic materials. State resolved and remaining issues, at most five, with concrete image evidence. Return PASS, REVISE or BLOCKED; distinguish required corrections from optional refinements. Do not edit, delegate or invoke other agents.' > /tmp/interceptor-critic-final.txt" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

 succeeded in 49046ms:
 exited 143 in 16367ms:
context compacted
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'scp gex44:/root/fal-author/interceptor-hardsf-v1/paints.png /tmp/interceptor-runtime/paints.png && scp gex44:/root/fal-author/interceptor-hardsf-v1/evidence/pivot_test.png /tmp/interceptor-runtime/pivot_test.png' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 2006ms:
hook: PreToolUse
hook: PreToolUse Completed
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'cat /tmp/interceptor-critic-final.txt' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
**Verdict: PASS**

The r4 build ([interceptor-final-r4.png](file:///tmp/interceptor-final-r4.png), [side.png](file:///tmp/interceptor-runtime/side.png), [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png), [rear.png](file:///tmp/interceptor-runtime/rear.png)) resolves the geometry, shading, and material defects from [interceptor-final-r3.png](file:///tmp/interceptor-final-r3.png) while meeting all audit and brief rules (16m hard-SF interceptor, `.55, .56, .55` light-grey paint, deployed radiators, allowed custom nose and engine detail).

### Resolved and Remaining Issues (Max 5)

| # | Status | Severity | Evidence & Finding |
| :--- | :--- | :--- | :--- |
| 1 | **Resolved** | Was Required | **Hull & nose triangle faceting removed:** In [interceptor-final-r3.png](file:///tmp/interceptor-final-r3.png), the forward nose and cheek panels showed harsh diagonal triangle shading and a sharp upward chine spike beside the windshield. In [interceptor-final-r4.png](file:///tmp/interceptor-final-r4.png) and [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png), the hull shading is smooth and the stray chine spike is gone. |
| 2 | **Resolved** | Was Required | **Photographic metals and mandatory light-grey paint:** In [interceptor-final-r3.png](file:///tmp/interceptor-final-r3.png), tank domes, radiators, and engine bell had a flat brown tint. In [interceptor-final-r4.png](file:///tmp/interceptor-final-r4.png) and [rear.png](file:///tmp/interceptor-runtime/rear.png), the propellant tanks read as brushed steel with weathered domes, the nozzle reads as dark ribbed alloy, and the hull carries the `.55, .56, .55` light-grey paint with panel seams and fasteners. |
| 3 | **Remaining** | Optional refinement | **Cockpit floor cutouts / bright cyan show-through:** In [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png) and [interceptor-final-r4.png](file:///tmp/interceptor-final-r4.png), looking down through the front and lower-side glass panes shows bright cyan-tinted gaps around the seat bases where the cockpit tub floor does not fully block the inner glazing reflection, plus a small step gap between the segmented side sill blocks below the canopy frame. |
| 4 | **Remaining** | Optional refinement | **Shallow side intake recess and shading gradient:** In [side.png](file:///tmp/interceptor-runtime/side.png) and [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png), the mid-hull intake is a shallow black-filled box with a vertical normal-map shading gradient directly below it, rather than the deeper faired intake ramp in [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg). |
| 5 | **Remaining** | Optional refinement | **Dorsal nose plate seam alignment:** In [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png), the four raised dorsal plates just ahead of the front windshield meet in an X-seam where the forward-most plate edge steps slightly above the smooth nose cone contour. |

* **Required corrections:** None.
* **Optional refinements:** Seal the lower cockpit tub floor to stop bright cyan show-through under the seats, smooth the vertical shading gradient beneath the side intake box, and flush-fit the front dorsal nose plate lip.

---

0. **Blocked on you**: nothing.
1. **What you changed**: Nothing; this was a read-only visual inspection comparing the r4 renders against r3 and the concept sheet.
2. **How you know it works**: Inspected [sheet.jpg](file:///home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1/sheet.jpg), [interceptor-final-r3.png](file:///tmp/interceptor-final-r3.png), [interceptor-final-r4.png](file:///tmp/interceptor-final-r4.png), [side.png](file:///tmp/interceptor-runtime/side.png), [cockpit.png](file:///tmp/interceptor-runtime/cockpit.png), and [rear.png](file:///tmp/interceptor-runtime/rear.png) directly.
3. **What would break if you were wrong**: If the bright cyan patches inside the lower cockpit glass are noticeable in close-up camera shots, the cockpit interior tub would need a darker closed floor mesh.
4. **What you did NOT do**: Did not inspect the GLB mesh or textures directly on disk, and did not edit any files, as instructed for this image-only review.

exec
/bin/bash -lc 'cat /tmp/interceptor-runtime/runtime.json; cat /tmp/interceptor-contract-final.json; cat /tmp/interceptor-audit-final.json' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
{
  "bounds": {
    "min": [
      -3.249982476234436,
      -2.031895160675049,
      -7.9951887130737305
    ],
    "max": [
      3.2500174045562744,
      2.168104648590088,
      8.004810333251953
    ],
    "size": [
      6.4999998807907104,
      4.199999809265137,
      15.999999046325684
    ]
  },
  "drawCalls": 19,
  "triangles": 88303,
  "meshes": 17,
  "materials": [
    "Paint",
    "Alloy",
    "Cockpit",
    "Glass",
    "Radiator",
    "Heatshield",
    "Lamps",
    "Engines"
  ],
  "webgl": "WebGL 2.0 (OpenGL ES 3.0 Chromium)",
  "paintFactor": [
    0.55,
    0.56,
    0.55
  ],
  "errors": []
}{
  "file": "interceptor-hardsf.glb",
  "sha256": "4e553f969f52a7d0b57a30f06d00bd9a0c91b787315986d76d87807d5959b7e1",
  "verdict": "PASS",
  "triangles": 87989,
  "exported_vertices": 90269,
  "primitives": 17,
  "image_gpu_mib_rgba8_with_mips": 60.19,
  "checks": {
    "triangle_budget": {
      "pass": true,
      "detail": 87989
    },
    "file_budget": {
      "pass": true,
      "detail": 8782724
    },
    "material_budget": {
      "pass": true,
      "detail": [
        "Glass",
        "Cockpit",
        "Alloy",
        "Radiator",
        "Heatshield",
        "Paint",
        "Lamps",
        "Engines"
      ]
    },
    "paint_factor": {
      "pass": true,
      "detail": null
    },
    "glass_blend": {
      "pass": true,
      "detail": null
    },
    "emissive_contract": {
      "pass": true,
      "detail": null
    },
    "no_cameras_lights": {
      "pass": true,
      "detail": null
    },
    "named_nodes": {
      "pass": true,
      "detail": [
        "Camera_Chase",
        "Canopy",
        "Hardpoint_1",
        "Hardpoint_2",
        "Light_Beacon",
        "Light_Nav_L",
        "Light_Nav_R",
        "Muzzle_1",
        "Radiator_L",
        "Radiator_R",
        "Seat_Copilot",
        "Seat_Pilot",
        "Thruster_1",
        "Thruster_2",
        "Thruster_3",
        "Thruster_4",
        "Thruster_5",
        "Thruster_6",
        "Thruster_7",
        "Thruster_8",
        "Thruster_9",
        "Thruster_10",
        "Thruster_11",
        "Thruster_12",
        "Thruster_13",
        "Thruster_14",
        "Thruster_15",
        "Thruster_16",
        "Thruster_17",
        "Hull"
      ]
    },
    "moving_identity_scale": {
      "pass": true,
      "detail": null
    },
    "embedded_images": {
      "pass": true,
      "detail": null
    },
    "image_limit": {
      "pass": true,
      "detail": [
        {
          "name": "Glass_Normal",
          "size": [
            64,
            64
          ],
          "bytes": 412
        },
        {
          "name": "Glass_Base",
          "size": [
            64,
            64
          ],
          "bytes": 415
        },
        {
          "name": "Glass_ORM",
          "size": [
            64,
            64
          ],
          "bytes": 415
        },
        {
          "name": "Cockpit_Normal",
          "size": [
            1024,
            1024
          ],
          "bytes": 328834
        },
        {
          "name": "Cockpit_ORM",
          "size": [
            512,
            512
          ],
          "bytes": 311056
        },
        {
          "name": "Cockpit_Base",
          "size": [
            1024,
            1024
          ],
          "bytes": 171945
        },
        {
          "name": "Alloy_Normal",
          "size": [
            1024,
            1024
          ],
          "bytes": 94136
        },
        {
          "name": "Alloy_ORM",
          "size": [
            512,
            512
          ],
          "bytes": 314880
        },
        {
          "name": "Alloy_Base",
          "size": [
            1024,
            1024
          ],
          "bytes": 419733
        },
        {
          "name": "Radiator_Normal",
          "size": [
            1024,
            1024
          ],
          "bytes": 80950
        },
        {
          "name": "Radiator_ORM",
          "size": [
            512,
            512
          ],
          "bytes": 314917
        },
        {
          "name": "Radiator_Base",
          "size": [
            1024,
            1024
          ],
          "bytes": 325058
        },
        {
          "name": "Heatshield_Normal",
          "size": [
            1024,
            1024
          ],
          "bytes": 67157
        },
        {
          "name": "Heatshield_ORM",
          "size": [
            512,
            512
          ],
          "bytes": 314882
        },
        {
          "name": "Heatshield_Base",
          "size": [
            1024,
            1024
          ],
          "bytes": 1336709
        },
        {
          "name": "Paint_Normal",
          "size": [
            1024,
            1024
          ],
          "bytes": 80950
        },
        {
          "name": "Paint_ORM",
          "size": [
            512,
            512
          ],
          "bytes": 314779
        },
        {
          "name": "Paint_Base",
          "size": [
            1024,
            1024
          ],
          "bytes": 864611
        },
        {
          "name": "Lamps_Base",
          "size": [
            64,
            64
          ],
          "bytes": 441
        },
        {
          "name": "Lamps_Normal",
          "size": [
            64,
            64
          ],
          "bytes": 412
        },
        {
          "name": "Lamps_ORM",
          "size": [
            64,
            64
          ],
          "bytes": 413
        },
        {
          "name": "Engines_Base",
          "size": [
            64,
            64
          ],
          "bytes": 415
        },
        {
          "name": "Engines_Normal",
          "size": [
            64,
            64
          ],
          "bytes": 412
        },
        {
          "name": "Engines_ORM",
          "size": [
            64,
            64
          ],
          "bytes": 414
        }
      ]
    },
    "Glass_ORM_limit": {
      "pass": true,
      "detail": null
    },
    "Cockpit_ORM_limit": {
      "pass": true,
      "detail": null
    },
    "Cockpit_AO_limit": {
      "pass": true,
      "detail": null
    },
    "Alloy_ORM_limit": {
      "pass": true,
      "detail": null
    },
    "Alloy_AO_limit": {
      "pass": true,
      "detail": null
    },
    "Radiator_ORM_limit": {
      "pass": true,
      "detail": null
    },
    "Radiator_AO_limit": {
      "pass": true,
      "detail": null
    },
    "Heatshield_ORM_limit": {
      "pass": true,
      "detail": null
    },
    "Heatshield_AO_limit": {
      "pass": true,
      "detail": null
    },
    "Paint_ORM_limit": {
      "pass": true,
      "detail": null
    },
    "Paint_AO_limit": {
      "pass": true,
      "detail": null
    },
    "Lamps_ORM_limit": {
      "pass": true,
      "detail": null
    },
    "Engines_ORM_limit": {
      "pass": true,
      "detail": null
    },
    "mesh_nodes_only_expected": {
      "pass": true,
      "detail": null
    }
  },
  "issues": []
}
{
  "file": "interceptor-hardsf.glb",
  "sha256": "4e553f969f52a7d0b57a30f06d00bd9a0c91b787315986d76d87807d5959b7e1",
  "bytes": 8782724,
  "primitive_count": 17,
  "material_count": 8,
  "embedded_images": 24,
  "external_uris": [],
  "primitives": [
    {
      "mesh": "Forward laminated glass end",
      "primitive": 0,
      "triangles": 146,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Forward laminated glass end",
      "primitive": 1,
      "triangles": 1446,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Forward laminated glass end",
      "primitive": 2,
      "triangles": 2768,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Radiator cassette",
      "primitive": 0,
      "triangles": 430,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Radiator cassette",
      "primitive": 1,
      "triangles": 2714,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Radiator cassette",
      "primitive": 2,
      "triangles": 1600,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Radiator cassette.005",
      "primitive": 0,
      "triangles": 434,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Radiator cassette.005",
      "primitive": 1,
      "triangles": 2710,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Radiator cassette.005",
      "primitive": 2,
      "triangles": 1600,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Continuous pressure hull",
      "primitive": 0,
      "triangles": 7760,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Continuous pressure hull",
      "primitive": 1,
      "triangles": 40692,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Continuous pressure hull",
      "primitive": 2,
      "triangles": 3428,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Continuous pressure hull",
      "primitive": 3,
      "triangles": 168,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Continuous pressure hull",
      "primitive": 4,
      "triangles": 1414,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Continuous pressure hull",
      "primitive": 5,
      "triangles": 18746,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Continuous pressure hull",
      "primitive": 6,
      "triangles": 510,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    },
    {
      "mesh": "Continuous pressure hull",
      "primitive": 7,
      "triangles": 1423,
      "zero_area_triangles": 0,
      "nonfinite_attribute_components": 0
    }
  ],
  "duplicate_primitives": [],
  "note": "Mesh-local counts, before node transforms/instances. Degenerate threshold: cross-product magnitude < 1e-12 in asset units squared. Not a source-topology or visual-quality verdict.",
  "triangles": 87989,
  "zero_area_triangles": 0,
  "nonfinite_attribute_components": 0,
  "findings": []
}

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

codex
The final asset passes the geometry and export checks: 87,989 triangles, eight materials, and an 8.78 MB GLB. The canopy and radiators stay attached during the 30° pivot test, and all six paint variants work. I’m collecting the final files and validation evidence now.
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'rg --files /tmp/interceptor-runtime; tail -75 build_interceptor-hardsf.py' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
/tmp/interceptor-runtime/interceptor-hardsf.glb
/tmp/interceptor-runtime/rear.png
/tmp/interceptor-runtime/contract.py
/tmp/interceptor-runtime/runtime.png
/tmp/interceptor-runtime/capture.log
/tmp/interceptor-runtime/index.html
/tmp/interceptor-runtime/metal02.jpg
/tmp/interceptor-runtime/clay.png
/tmp/interceptor-runtime/paints.png
/tmp/interceptor-runtime/runtime_navy.png
/tmp/interceptor-runtime/runtime_ao_off.png
/tmp/interceptor-runtime/runtime_maps_off.png
/tmp/interceptor-runtime/pivot_test.png
/tmp/interceptor-runtime/capture.mjs
/tmp/interceptor-runtime/package.json
/tmp/interceptor-runtime/cockpit.png
/tmp/interceptor-runtime/runtime_normal_off.png
/tmp/interceptor-runtime/metal.jpg
/tmp/interceptor-runtime/runtime.json
/tmp/interceptor-runtime/side.png
/tmp/interceptor-runtime/Paint_Base.png
/tmp/interceptor-runtime/server.log
/tmp/interceptor-runtime/package-lock.json
/tmp/interceptor-runtime/node_modules/playwright/NOTICE
/tmp/interceptor-runtime/node_modules/playwright/LICENSE
/tmp/interceptor-runtime/node_modules/playwright/index.js
/tmp/interceptor-runtime/node_modules/playwright/test.d.ts
/tmp/interceptor-runtime/node_modules/playwright/ThirdPartyNotices.txt
/tmp/interceptor-runtime/node_modules/playwright/test.js
/tmp/interceptor-runtime/node_modules/playwright/test.mjs
/tmp/interceptor-runtime/node_modules/playwright/package.json
/tmp/interceptor-runtime/node_modules/playwright/index.mjs
/tmp/interceptor-runtime/node_modules/playwright/lib/package.js
/tmp/interceptor-runtime/node_modules/playwright/lib/index.js
/tmp/interceptor-runtime/node_modules/playwright/lib/isomorphic.js
/tmp/interceptor-runtime/node_modules/playwright/lib/program.js
/tmp/interceptor-runtime/node_modules/playwright/lib/errorContext.js
/tmp/interceptor-runtime/node_modules/playwright/lib/globals.js
/tmp/interceptor-runtime/node_modules/playwright/cli.js
/tmp/interceptor-runtime/node_modules/playwright/lib/runner/index.js.txt
/tmp/interceptor-runtime/node_modules/playwright/lib/runner/index.js
/tmp/interceptor-runtime/node_modules/playwright/lib/util.js
/tmp/interceptor-runtime/node_modules/playwright/types/test.d.ts
/tmp/interceptor-runtime/node_modules/playwright/types/testReporter.d.ts
/tmp/interceptor-runtime/node_modules/playwright/index.d.ts
/tmp/interceptor-runtime/node_modules/playwright/README.md
/tmp/interceptor-runtime/node_modules/playwright/lib/matchers/expect.js.LICENSE
/tmp/interceptor-runtime/node_modules/playwright/lib/matchers/expect.js.txt
/tmp/interceptor-runtime/node_modules/playwright/lib/matchers/expect.js
/tmp/interceptor-runtime/node_modules/playwright/lib/common/index.js
/tmp/interceptor-runtime/node_modules/playwright/lib/common/index.js.txt
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/playwright-test-coverage.prompt.md
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/playwright-test-heal.prompt.md
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/agentParser.js
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/playwright-test-generator.agent.md
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/generateAgents.js
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/playwright-test-healer.agent.md
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/playwright-test-plan.prompt.md
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/playwright-test-planner.agent.md
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/playwright-test-generate.prompt.md
/tmp/interceptor-runtime/node_modules/playwright/lib/agents/copilot-setup-steps.yml
/tmp/interceptor-runtime/node_modules/playwright/lib/isomorphic.js.txt
/tmp/interceptor-runtime/node_modules/playwright/lib/loader/loaderProcessEntry.js
/tmp/interceptor-runtime/node_modules/playwright/lib/loader/loaderProcessEntry.js.txt
/tmp/interceptor-runtime/node_modules/three/README.md
/tmp/interceptor-runtime/node_modules/three/package.json
/tmp/interceptor-runtime/node_modules/playwright/lib/cli/testActions.js
/tmp/interceptor-runtime/node_modules/playwright/lib/cli/reportActions.js
/tmp/interceptor-runtime/node_modules/three/LICENSE
/tmp/interceptor-runtime/node_modules/playwright/lib/worker/workerProcessEntry.js.txt
/tmp/interceptor-runtime/node_modules/playwright/lib/worker/workerProcessEntry.js
/tmp/interceptor-runtime/node_modules/three/src/Three.WebGPU.Nodes.js
/tmp/interceptor-runtime/node_modules/playwright/lib/transform/babelBundle.js.txt
/tmp/interceptor-runtime/node_modules/playwright/lib/transform/esmLoader.js.LICENSE
/tmp/interceptor-runtime/node_modules/playwright/lib/transform/esmLoader.js.txt
/tmp/interceptor-runtime/node_modules/playwright/lib/transform/babelBundle.js.LICENSE
/tmp/interceptor-runtime/node_modules/playwright/lib/transform/babelBundle.js
/tmp/interceptor-runtime/node_modules/playwright/lib/transform/esmLoader.js
/tmp/interceptor-runtime/node_modules/three/build/three.webgpu.nodes.js
/tmp/interceptor-runtime/node_modules/three/build/three.webgpu.js
/tmp/interceptor-runtime/node_modules/three/build/three.tsl.js
/tmp/interceptor-runtime/node_modules/three/build/three.module.js
/tmp/interceptor-runtime/node_modules/three/build/three.core.js
/tmp/interceptor-runtime/node_modules/three/build/three.cjs
/tmp/interceptor-runtime/node_modules/three/src/Three.WebGPU.js
/tmp/interceptor-runtime/node_modules/playwright/lib/mcp/test/generatorTools.js
/tmp/interceptor-runtime/node_modules/playwright/lib/mcp/test/testBackend.js
/tmp/interceptor-runtime/node_modules/playwright/lib/mcp/test/testTool.js
/tmp/interceptor-runtime/node_modules/playwright/lib/mcp/test/browserBackend.js
/tmp/interceptor-runtime/node_modules/playwright/lib/mcp/test/plannerTools.js
/tmp/interceptor-runtime/node_modules/playwright/lib/mcp/test/seed.js
/tmp/interceptor-runtime/node_modules/playwright/lib/mcp/test/streams.js
/tmp/interceptor-runtime/node_modules/playwright/lib/mcp/test/testTools.js
/tmp/interceptor-runtime/node_modules/playwright/lib/mcp/test/testContext.js
/tmp/interceptor-runtime/node_modules/three/src/math/Interpolant.js
/tmp/interceptor-runtime/node_modules/three/src/math/Plane.js
/tmp/interceptor-runtime/node_modules/three/src/math/Ray.js
/tmp/interceptor-runtime/node_modules/three/src/math/Spherical.js
/tmp/interceptor-runtime/node_modules/three/src/math/Color.js
/tmp/interceptor-runtime/node_modules/three/src/math/FrustumArray.js
/tmp/interceptor-runtime/node_modules/three/src/math/Quaternion.js
/tmp/interceptor-runtime/node_modules/three/src/math/Box3.js
/tmp/interceptor-runtime/node_modules/three/src/math/Frustum.js
/tmp/interceptor-runtime/node_modules/three/src/math/SphericalHarmonics3.js
/tmp/interceptor-runtime/node_modules/three/src/math/Matrix3.js
/tmp/interceptor-runtime/node_modules/three/src/math/Cylindrical.js
/tmp/interceptor-runtime/node_modules/three/src/math/Line3.js
/tmp/interceptor-runtime/node_modules/three/src/math/Box2.js
/tmp/interceptor-runtime/node_modules/three/src/math/Vector3.js
/tmp/interceptor-runtime/node_modules/three/src/math/Vector4.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/RingGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/DodecahedronGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/CylinderGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/EdgesGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/CapsuleGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/CircleGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/ExtrudeGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/BoxGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/IcosahedronGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/TorusGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/ShapeGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/TetrahedronGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/Geometries.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/SphereGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/TorusKnotGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/LatheGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/TubeGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/PolyhedronGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/ConeGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/OctahedronGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/PlaneGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/geometries/WireframeGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/FileLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/LoaderUtils.js
/tmp/interceptor-runtime/node_modules/three/src/audio/AudioAnalyser.js
/tmp/interceptor-runtime/node_modules/three/src/audio/Audio.js
/tmp/interceptor-runtime/node_modules/three/src/audio/AudioListener.js
/tmp/interceptor-runtime/node_modules/three/src/audio/AudioContext.js
/tmp/interceptor-runtime/node_modules/three/src/audio/PositionalAudio.js
/tmp/interceptor-runtime/node_modules/three/src/Three.Core.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/ObjectLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/BufferGeometryLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/ImageLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/Loader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/CompressedTextureLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/TextureLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/AudioLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/Cache.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/AnimationLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/ImageBitmapLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/CubeTextureLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/LoadingManager.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/SimplexNoise.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/Octree.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/MeshSurfaceSampler.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/ColorConverter.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/Lut.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/OBB.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/Capsule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/ImprovedNoise.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/ColorSpaces.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/math/ConvexHull.js
/tmp/interceptor-runtime/node_modules/three/src/math/interpolants/DiscreteInterpolant.js
/tmp/interceptor-runtime/node_modules/three/src/math/interpolants/QuaternionLinearInterpolant.js
/tmp/interceptor-runtime/node_modules/three/src/math/interpolants/LinearInterpolant.js
/tmp/interceptor-runtime/node_modules/three/src/math/interpolants/CubicInterpolant.js
/tmp/interceptor-runtime/node_modules/three/src/math/interpolants/BezierInterpolant.js
/tmp/interceptor-runtime/node_modules/three/src/math/MathUtils.js
/tmp/interceptor-runtime/node_modules/three/src/math/Matrix4.js
/tmp/interceptor-runtime/node_modules/three/src/math/Euler.js
/tmp/interceptor-runtime/node_modules/three/src/math/ColorManagement.js
/tmp/interceptor-runtime/node_modules/three/src/math/Vector2.js
/tmp/interceptor-runtime/node_modules/three/src/math/Triangle.js
/tmp/interceptor-runtime/node_modules/three/src/math/Sphere.js
/tmp/interceptor-runtime/node_modules/three/src/math/Matrix2.js
/tmp/interceptor-runtime/node_modules/playwright-core/NOTICE
/tmp/interceptor-runtime/node_modules/playwright-core/LICENSE
/tmp/interceptor-runtime/node_modules/three/src/animation/AnimationAction.js
/tmp/interceptor-runtime/node_modules/three/src/animation/AnimationUtils.js
/tmp/interceptor-runtime/node_modules/three/src/animation/KeyframeTrack.js
/tmp/interceptor-runtime/node_modules/three/src/animation/AnimationMixer.js
/tmp/interceptor-runtime/node_modules/three/src/animation/AnimationObjectGroup.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshDepthMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshDistanceMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/RawShaderMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshNormalMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/scenes/FogExp2.js
/tmp/interceptor-runtime/node_modules/three/src/scenes/Fog.js
/tmp/interceptor-runtime/node_modules/three/src/scenes/Scene.js
/tmp/interceptor-runtime/node_modules/three/src/materials/SpriteMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshMatcapMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/ShadowMaterial.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/physics/AmmoPhysics.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/physics/JoltPhysics.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/physics/RapierPhysics.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/nodes/NodeObjectLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/nodes/NodeMaterialLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/nodes/NodeLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/MaterialLoader.js
/tmp/interceptor-runtime/node_modules/three/src/loaders/DataTextureLoader.js
/tmp/interceptor-runtime/node_modules/three/src/Three.js
/tmp/interceptor-runtime/node_modules/three/src/Three.TSL.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/shapes/Shapes.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/Nodes.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/math/Bayer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/math/curlNoise.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/math/voronoiNoise.js
/tmp/interceptor-runtime/node_modules/three/src/materials/ShaderMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/LineBasicMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshPhongMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/Materials.js
/tmp/interceptor-runtime/node_modules/three/src/animation/tracks/BooleanKeyframeTrack.js
/tmp/interceptor-runtime/node_modules/three/src/animation/tracks/VectorKeyframeTrack.js
/tmp/interceptor-runtime/node_modules/three/src/animation/tracks/NumberKeyframeTrack.js
/tmp/interceptor-runtime/node_modules/three/src/animation/tracks/StringKeyframeTrack.js
/tmp/interceptor-runtime/node_modules/three/src/animation/tracks/QuaternionKeyframeTrack.js
/tmp/interceptor-runtime/node_modules/three/src/animation/tracks/ColorKeyframeTrack.js
/tmp/interceptor-runtime/node_modules/three/src/animation/PropertyBinding.js
/tmp/interceptor-runtime/node_modules/three/src/animation/AnimationClip.js
/tmp/interceptor-runtime/node_modules/three/src/animation/PropertyMixer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/Addons.js
/tmp/interceptor-runtime/node_modules/three/src/materials/PointsMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshBasicMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshLambertMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshStandardMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/LineDashedMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/Material.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshPhysicalMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/MeshToonMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/objects/Points.js
/tmp/interceptor-runtime/node_modules/three/src/objects/Bone.js
/tmp/interceptor-runtime/node_modules/three/src/objects/Line.js
/tmp/interceptor-runtime/node_modules/three/src/objects/SkinnedMesh.js
/tmp/interceptor-runtime/node_modules/three/src/objects/LineLoop.js
/tmp/interceptor-runtime/node_modules/three/src/objects/BatchedMesh.js
/tmp/interceptor-runtime/node_modules/three/src/objects/ClippingGroup.js
/tmp/interceptor-runtime/node_modules/three/src/objects/Skeleton.js
/tmp/interceptor-runtime/node_modules/three/src/objects/Sprite.js
/tmp/interceptor-runtime/node_modules/three/src/objects/Group.js
/tmp/interceptor-runtime/node_modules/three/src/objects/LOD.js
/tmp/interceptor-runtime/node_modules/three/src/objects/InstancedMesh.js
/tmp/interceptor-runtime/node_modules/three/src/objects/LineSegments.js
/tmp/interceptor-runtime/node_modules/three/src/objects/Mesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/utils/RNoise.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/controls/FirstPersonControls.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/controls/MapControls.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/controls/TransformControls.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/controls/DragControls.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/controls/OrbitControls.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/controls/TrackballControls.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/controls/ArcballControls.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/controls/FlyControls.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/controls/PointerLockControls.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/utils/SpecularHelpers.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/utils/SoftParticles.js
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/install_webkit_wsl.ps1
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh
/tmp/interceptor-runtime/node_modules/playwright-core/bin/install_media_pack.ps1
/tmp/interceptor-runtime/node_modules/playwright-core/index.js
/tmp/interceptor-runtime/node_modules/playwright-core/ThirdPartyNotices.txt
/tmp/interceptor-runtime/node_modules/playwright-core/browsers.json
/tmp/interceptor-runtime/node_modules/playwright-core/package.json
/tmp/interceptor-runtime/node_modules/playwright-core/index.mjs
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/utils/TAAUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/utils/GroundedSkybox.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/utils/Raymarching.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/WebGLNodesHandler.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/PhongLightingModel.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BasicLightingModel.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/PositionalAudioHelper.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/PointsNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/core/Clock.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/LightProbeGridHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/OctreeHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/AnimationPathHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/LightProbeHelperGPU.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/VertexNormalsHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/LightProbeGridHelperWebGL.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/ViewHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/TextureHelperGPU.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/RapierHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/RectAreaLightHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/TextureHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/VertexTangentsHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/helpers/LightProbeHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/Refractor.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/Reflector.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/Water.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/ReflectorForSSRPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/Water2.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/GaussianSplat.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/Sky.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/MarchingCubes.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/LensflareMesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/Water2Mesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/ShadowMesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/WaterMesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/Lensflare.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/GroundedSkybox.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/objects/SkyMesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/animation/AnimationClipCreator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/animation/CCDIKSolver.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/NodeMaterials.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/material/getRoughness.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/material/getAlphaHashThreshold.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/material/getParallaxCorrectNormal.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/material/getShIrradianceAt.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/material/getGeometryRoughness.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/VolumetricLightingModel.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/ToonLightingModel.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/PhysicalLightingModel.js
/tmp/interceptor-runtime/node_modules/three/src/core/Raycaster.js
/tmp/interceptor-runtime/node_modules/three/src/core/InstancedBufferGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/core/Timer.js
/tmp/interceptor-runtime/node_modules/three/src/core/InstancedInterleavedBuffer.js
/tmp/interceptor-runtime/node_modules/three/src/core/EventDispatcher.js
/tmp/interceptor-runtime/node_modules/three/src/core/GLBufferAttribute.js
/tmp/interceptor-runtime/node_modules/three/src/core/InstancedBufferAttribute.js
/tmp/interceptor-runtime/node_modules/three/src/core/InterleavedBuffer.js
/tmp/interceptor-runtime/node_modules/three/src/core/RenderTarget.js
/tmp/interceptor-runtime/node_modules/three/src/core/BufferAttribute.js
/tmp/interceptor-runtime/node_modules/three/src/core/Uniform.js
/tmp/interceptor-runtime/node_modules/three/src/core/Object3D.js
/tmp/interceptor-runtime/node_modules/three/src/core/UniformsGroup.js
/tmp/interceptor-runtime/node_modules/three/src/core/BufferGeometry.js
/tmp/interceptor-runtime/node_modules/three/src/core/Layers.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/RetroPassNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/ParallaxBarrierPassNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/TRAANode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/GodraysNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/OITPassNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/PixelationPassNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/FXAANode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/LensflareNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/AnaglyphPassNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/DotScreenNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/Shape.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/SSGINode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/AfterImageNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/boxBlur.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/depthAwareBlend.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/depthAwareBlur.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/TemporalReprojectNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/TransitionNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/OutlineNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/SobelOperatorNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/BilateralBlurNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/CRT.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/RecurrentDenoiseNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/MotionBlur.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/ChromaticAberrationNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/Sepia.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/DenoiseNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/SSAAPassNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/hashBlur.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/SSSNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/StereoPassNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/StereoCompositePassNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/SSRNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/SSAONode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/DepthOfFieldNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/BleachBypass.js
/tmp/interceptor-runtime/node_modules/three/src/core/InterleavedBufferAttribute.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/SMAANode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/ImportanceSampledEnvironment.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/GaussianBlurNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/Lut3DNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/RGBShiftNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/TAAUNode.js
/tmp/interceptor-runtime/node_modules/three/src/core/RenderTarget3D.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/FilmNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/GTAONode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/SharpenNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/FSR1Node.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/BloomNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/display/radialBlur.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/manager/NodeMaterialObserver.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/Line2NodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/MeshToonNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/MeshMatcapNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/LineBasicNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/MeshLambertNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/MeshBasicNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/NodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/SpriteNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/VolumeNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/ShadowNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/MeshStandardNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/MeshPhysicalNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/MeshSSSNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/LineDashedNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/MeshPhongNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/materials/nodes/MeshNormalNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lighting/ClusteredLighting.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/ui/List.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/ui/Profiler.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/ui/utils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/ui/Values.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/ui/Item.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/ui/Style.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/ui/Tab.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/ui/Graph.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/DirectionalLightHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/PlaneHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/ArrowHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/SpotLightHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/Box3Helper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/CameraHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/PointLightHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/PolarGridHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/HemisphereLightHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/AxesHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/GridHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/SkeletonHelper.js
/tmp/interceptor-runtime/node_modules/three/src/helpers/BoxHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/lighting/data/SpotLightDataNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/lighting/data/AmbientLightDataNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/lighting/data/DirectionalLightDataNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/lighting/data/PointLightDataNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/lighting/data/HemisphereLightDataNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/lighting/DynamicLightsNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/lighting/ClusteredLightsNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/lighting/LightProbeGridNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/tabs/Settings.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/tabs/Parameters.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/tabs/Performance.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/tabs/Console.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/tabs/Timeline.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/tabs/Viewer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/tabs/Memory.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/Inspector.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.C8BeHuxd.css
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CyMfwkXJ.css
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/index.html
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/shadows/TileShadowNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/tsl/shadows/TileShadowNodeHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/LineSegments2.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/Wireframe.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/WireframeGeometry2.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/BRDF_GGX.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/Schlick_to_F0.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/BRDF_Sheen.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/V_GGX_SmithCorrelated_Anisotropic.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/D_GGX.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/LTC.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/F_Schlick.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/V_GGX_SmithCorrelated.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/EnvironmentBRDF.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/DFGLUT.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/BRDF_Lambert.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/BSDF/D_GGX_Anisotropic.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/functions/ShadowMaskModel.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/UniformsLib.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/UniformsUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/DFGLUTData.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webxr/WebXRController.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webxr/WebXRManager.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webxr/WebXRDepthSensing.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/WebGLRenderTarget.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lighting/vxgi/VXGIVolume.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lighting/vxgi/VXGINode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lighting/vxgi/VXGISceneCollector.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lighting/vxgi/VXGIConeTracer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lights/RectAreaLightUniformsLib.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lights/SunLight.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lighting/LightProbeGridWebGL.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lighting/LightProbeGridUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lights/SunLightNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lighting/LightProbeGrid.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lighting/DynamicLighting.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lights/RectAreaLightTexturesLib.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lights/LightProbeGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lights/SunShadowNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lights/SunLightShadow.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/Hash.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/Packed4x8IntegerNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/BitcastNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/UnpackFloatNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/BitcountNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/OperatorNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/PackFloatNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/MathUtils.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/ConditionalNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/MathNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/math/TriNoise3D.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/tsl-graph/TSLGraphEditor.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/webgpu/LineSegments2.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/tsl-graph/TSLGraphLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/capabilities/WebGPU.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/IndexNode.js
/tmp/interceptor-runtime/node_modules/three/src/extras/core/Shape.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/capabilities/WebGL.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/webgpu/Wireframe.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/webgpu/Line2.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/LineMaterial.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/Line2.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/LineSegmentsGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/lines/LineGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/basis/basis_transcoder.wasm
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/CurveEditor.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/AttributeNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/OutputStructNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeBuilder.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeUniform.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/UniformNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/ParameterNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/LightingModel.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/VarNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeUtils.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/UniformGroupNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/ConstNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/StackNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/Node.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/constants.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/ContextNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeFunctionInput.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeVar.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeCache.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/StructNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/VaryingNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/TempNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/PropertyNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/ArrayNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/StackTrace.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeFunction.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/BypassNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeError.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/InputNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/AssignNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/MRTNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeFrame.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/InspectorNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeVarying.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/OverrideContextNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeCode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/StructType.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/IsolateNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeAttribute.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/StructTypeNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/NodeParser.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/core/SubBuildNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/TSL.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/basis/basis_transcoder.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/LUT3DStyle.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/LUTMath.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/ColorGrading.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/ColorWheel.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/iridescence_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/envmap_common_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/morphcolor_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/emissivemap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_phong_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/map_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/batching_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/begin_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_physical_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/envmap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/default_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/alphamap_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/cube_uv_reflection_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/bsdfs.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/uv_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/normal_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/clipping_planes_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/dithering_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/morphtarget_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/normal_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/fog_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/envmap_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/specularmap_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/beginnormal_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_toon_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/aomap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/transmission_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/clearcoat_normal_fragment_maps.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/envmap_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/morphinstance_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/clipping_planes_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/skinning_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/default_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/roughnessmap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/map_particle_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/morphtarget_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/fog_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/fog_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/bumpmap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/shadowmap_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/shadowmask_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/tonemapping_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/dithering_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/alphahash_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/logdepthbuf_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/packing.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/colorspace_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/color_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/morphnormal_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/color_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lightmap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/envmap_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/logdepthbuf_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/logdepthbuf_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/project_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/transmission_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/clearcoat_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_fragment_end.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/colorspace_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/alphatest_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/gradientmap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/color_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/normal_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/shadowmap_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/alphahash_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/map_particle_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/metalnessmap_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lightprobes_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_pars_begin.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/shadowmap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/uv_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/common.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/clearcoat_normal_fragment_begin.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/premultiplied_alpha_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/opaque_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/metalnessmap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/emissivemap_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_lambert_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/skinbase_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_fragment_begin.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/alphatest_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/envmap_physical_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/normal_fragment_begin.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/color_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/logdepthbuf_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/iridescence_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/displacementmap_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/clipping_planes_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/fog_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_physical_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/skinning_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/skinnormal_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/tonemapping_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/aomap_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/clipping_planes_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/normal_fragment_maps.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_toon_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/map_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/normalmap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/batching_pars_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_phong_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/defaultnormal_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/worldpos_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/specularmap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/alphamap_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/displacementmap_vertex.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/uv_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/roughnessmap_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_fragment_maps.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderChunk/lights_lambert_pars_fragment.glsl.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/basis/README.md
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/utif.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/meshopt_clusterizer.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/mikktspace.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/fflate.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/surfaceNet.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/lil-gui.module.min.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/zstddec.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/tween.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/meshopt_decoder.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/demuxer_mp4.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/ktx-parse.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/chevrotain.module.min.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/stats.module.js
/tmp/interceptor-runtime/node_modules/three/src/extras/core/Curve.js
/tmp/interceptor-runtime/node_modules/three/src/extras/core/Path.js
/tmp/interceptor-runtime/node_modules/three/src/extras/core/CurvePath.js
/tmp/interceptor-runtime/node_modules/three/src/extras/core/ShapePath.js
/tmp/interceptor-runtime/node_modules/three/src/extras/core/Interpolations.js
/tmp/interceptor-runtime/node_modules/three/src/extras/TextureUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/csm/CSMFrustum.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/materialx/MaterialXNodes.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/csm/CSM.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/csm/CSMShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/csm/CSMShadowNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/csm/CSMHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/BokehShader2.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/WaterRefractionShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/AfterimageShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/HueSaturationShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/DotScreenShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/UnpackDepthRGBAShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/ColorCorrectionShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/TriangleBlurShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/CopyShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/ACESFilmicToneMappingShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/FilmShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/GTAOShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/FreiChenShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/MirrorShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/DepthLimitedBlurShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/FXAAShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/RGBShiftShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/KaleidoShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/BleachBypassShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/VignetteShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/VelocityShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/HorizontalBlurShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/SepiaShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/DOFMipMapShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/LuminosityShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/VolumeShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/SobelOperatorShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/VerticalTiltShiftShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/OutputShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/TechnicolorShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/GammaCorrectionShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/DigitalGlitch.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/HalftoneShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/BrightnessContrastShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/ExposureShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/SSAOShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/SMAAShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/PoissonDenoiseShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/VerticalBlurShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/ConvolutionShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/BlendShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/BasicShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/HorizontalTiltShiftShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/LuminosityHighPassShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/SAOShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/BokehShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/FocusShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/SubsurfaceScatteringShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/ToonShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/SSRShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/ColorifyShader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/shaders/NormalMapShader.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/materialx/MaterialXColorTransform.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/materialx/MaterialXColor.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/materialx/MaterialXNoise.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/materialx/MaterialXCore.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/materialx/DISCLAIMER.md
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/draco/draco_decoder.wasm
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/draco/draco_decoder.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/background.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/backgroundCube.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/linedashed.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/vsm.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/shadow.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/sprite.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/meshtoon.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/distance.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/meshmatcap.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/points.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/meshphong.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/equirect.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/meshbasic.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/meshphysical.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/meshnormal.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/meshlambert.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/cube.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/shaders/ShaderLib/depth.glsl.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/WebGLRenderer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/WebGLArrayRenderTarget.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/WebGLCubeRenderTarget.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/SatVibranceModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/OutputModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/BrightnessModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/ColorWheelModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/SaturationModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/HueModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/VibranceModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/ExposureHueModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/Module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/WhiteBalanceModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/ContrastModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/CurvesModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/ExposureModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/ImportedCubeModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/extensions/color-grading/modules/RendererModule.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/Extension.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/inspector/RendererInspector.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-BEB2e8M3.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/UVUtils.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-D-g2vrvf.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-B794rD2Z.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css
/tmp/interceptor-runtime/node_modules/three/examples/jsm/curves/NURBSUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/curves/NURBSSurface.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/curves/CurveExtras.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/curves/NURBSVolume.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/curves/NURBSCurve.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/index.oNry3Hjv.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/playwright-logo.svg
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/codicon.DCmgc-ay.ttf
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/manifest.webmanifest
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CYwC_gH1.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/Discard.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/EquirectUV.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/Packing.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/SpriteSheetUV.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/ConvertNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/Oscillators.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/SampleNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/ViewportUtils.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/RotateNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/SpriteUtils.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/MatcapUV.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/Timer.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/PostProcessingUtils.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/FlipNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/ReflectorNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/FunctionOverloadingNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/JoinNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/SetNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/Remap.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/TriplanarTextures.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/CubeMapNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/DebugNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/MaxMipLevelNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/RTTNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/LoopNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/EventNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/MemberNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/ArrayElementNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/SplitNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/utils/StorageArrayElementNode.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/LineCurve3.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/QuadraticBezierCurve.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/CubicBezierCurve3.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/LineCurve.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/ArcCurve.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/EllipseCurve.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/SplineCurve.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/CubicBezierCurve.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/QuadraticBezierCurve3.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/Curves.js
/tmp/interceptor-runtime/node_modules/three/src/extras/curves/CatmullRomCurve3.js
/tmp/interceptor-runtime/node_modules/three/src/extras/Controls.js
/tmp/interceptor-runtime/node_modules/three/src/extras/ImageUtils.js
/tmp/interceptor-runtime/node_modules/three/src/extras/Earcut.js
/tmp/interceptor-runtime/node_modules/three/src/extras/PMREMGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/draco/gltf/draco_decoder.wasm
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/draco/gltf/draco_decoder.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/draco/gltf/draco_wasm_wrapper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/draco/draco_wasm_wrapper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/draco/README.md
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/potpack.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/meshopt_simplifier.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/libs/motion-controllers.module.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/interactive/HTMLMesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/interactive/InteractiveGroup.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/interactive/SelectionHelper.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/interactive/SelectionBox.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/NRRDLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/SPLATLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/GLTFGaussianSplatLoaderExtension.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/TTFLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/KSPLATLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/MTLLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/SVGLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/GCodeLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/VTKLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/PCDLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/KTX2Loader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/PDBLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/RGBELoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/AMFLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/3DMLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/MDDLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/VRMLLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/USDZLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/EffectComposer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/GTAOPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/LUTPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/HalftonePass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/SAOPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/SMAAPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/DotScreenPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/GlitchPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/RenderTransitionPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/SavePass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/BloomPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/SSAOPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/OutputPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/OutlinePass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/SSAARenderPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/SSRPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/AfterimagePass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/TAARenderPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/FilmPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/Pass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/RenderPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/FXAAPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/RenderPixelatedPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/ClearPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/ShaderPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/UnrealBloomPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/CubeTexturePass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/MaskPass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/TexturePass.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/postprocessing/BokehPass.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/tsl/TSLCore.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/transpiler/WGSLEncoder.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/tsl/TSLBase.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/transpiler/TSLEncoder.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/transpiler/AST.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/transpiler/Transpiler.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/transpiler/TranspilerUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/transpiler/ShaderToyDecoder.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/transpiler/GLSLDecoder.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/transpiler/Linker.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/Volume.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/TileCreasedNormalsPlugin.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/MorphAnimMesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/MD2CharacterComplex.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/ProgressiveLightMap.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/Sculptor.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/ConvexObjectBreaker.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/MorphBlendMesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/GPUComputationRenderer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/RollerCoaster.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/MD2Character.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/Gyroscope.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/SculptorUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/SculptGL.LICENSE.txt
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/SculptorMesh.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/TubePainter.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/ProgressiveLightMapGPU.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/SculptorTools.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/misc/VolumeSlice.js
/tmp/interceptor-runtime/node_modules/three/src/extras/lib/earcut.js
/tmp/interceptor-runtime/node_modules/three/src/extras/ShapeUtils.js
/tmp/interceptor-runtime/node_modules/three/src/extras/DataUtils.js
/tmp/interceptor-runtime/node_modules/three/src/Three.Legacy.js
/tmp/interceptor-runtime/node_modules/three/src/constants.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/MaterialXLog.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/MaterialXSurfaceMappings.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/utils/WebGPUCapabilities.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/MaterialXUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/utils/WebGPUBindingUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/utils/WebGPUPipelineUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/utils/WebGPUUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/utils/WebGPUTimestampQueryPool.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/utils/WebGPUTexturePassUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/utils/WebGPUAttributeUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/utils/WebGPUTextureUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/utils/WebGPUConstants.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/WebGPURenderer.Nodes.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/WebGPURenderer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/WebGPUBackend.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/geometries/ParametricGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/geometries/RoundedBoxGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/geometries/DecalGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/geometries/TextGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/geometries/LoftGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/geometries/TeapotGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/geometries/BoxLineGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/geometries/ConvexGeometry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/geometries/ParametricFunctions.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/interaction/InteractionManager.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/ColladaLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/TDSLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/renderers/CSS3DRenderer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/renderers/SVGRenderer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/renderers/Projector.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/renderers/CSS2DRenderer.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/code/FunctionCallNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/code/FunctionNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/code/CodeNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/code/ExpressionNode.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPURenderPassColorAttachment.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUQuerySetDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUSamplerDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUCopyExternalImageSourceInfo.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUComputePassDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUCommandEncoderDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUBindGroupDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUComputePipelineDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPURenderPipelineDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUTexelCopyBufferLayout.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPURenderBundleEncoderDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUTextureViewDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPURenderPassDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUPipelineLayoutDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUTexelCopyBufferInfo.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUCopyExternalImageDestInfo.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUBufferDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPURenderPassDepthStencilAttachment.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUTexelCopyTextureInfo.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUExtent3D.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUShaderModuleDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPUTextureDescriptor.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/descriptors/GPURenderPassTimestampWrites.js
/tmp/interceptor-runtime/node_modules/three/src/textures/CanvasTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/CubeDepthTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/VideoTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/Texture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/FramebufferTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/CompressedCubeTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/DataArrayTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/CubeTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/DepthTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/Data3DTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/HTMLTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/TextureSource.js
/tmp/interceptor-runtime/node_modules/three/src/textures/CompressedTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/DataTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/ExternalTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/CompressedArrayTexture.js
/tmp/interceptor-runtime/node_modules/three/src/textures/VideoFrameTexture.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/effects/AnaglyphEffect.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/effects/StereoEffect.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/effects/AsciiEffect.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/effects/OutlineEffect.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/effects/ParallaxBarrierEffect.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/nodes/StandardNodeLibrary.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/nodes/WGSLNodeBuilder.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/nodes/WGSLNodeParser.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/nodes/WGSLNodeFunction.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgpu/nodes/BasicNodeLibrary.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/WebGL3DRenderTarget.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/MaterialXInterfaceValidation.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/MaterialXDocument.js
/tmp/interceptor-runtime/node_modules/three/src/lights/SpotLightShadow.js
/tmp/interceptor-runtime/node_modules/three/src/lights/SpotLight.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/modifiers/EdgeSplitModifier.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/modifiers/CurveModifierGPU.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/modifiers/TessellateModifier.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/modifiers/SimplifyModifier.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/modifiers/CurveModifier.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/recorder/index.html
/tmp/interceptor-runtime/node_modules/three/examples/jsm/materials/LDrawConditionalLineNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/materials/WoodNodeMaterial.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/materials/LDrawConditionalLineMaterial.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Constants.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/InspectorBase.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/ChainMap.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderObjects.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderObject.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderContexts.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/StorageTexture.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/UniformBuffer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Background.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/BindGroup.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/CanvasTarget.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/BlendMode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/collada/ColladaParser.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/collada/ColladaComposer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/STLLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/FontLoader.js
/tmp/interceptor-runtime/node_modules/three/src/lights/webgpu/IESSpotLight.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/CityGenerator.js
/tmp/interceptor-runtime/node_modules/three/src/lights/webgpu/ProjectorLight.js
/tmp/interceptor-runtime/node_modules/three/src/lights/DirectionalLight.js
/tmp/interceptor-runtime/node_modules/three/src/lights/LightProbe.js
/tmp/interceptor-runtime/node_modules/three/src/lights/RectAreaLight.js
/tmp/interceptor-runtime/node_modules/three/src/lights/LightShadow.js
/tmp/interceptor-runtime/node_modules/three/src/lights/Light.js
/tmp/interceptor-runtime/node_modules/three/src/lights/HemisphereLight.js
/tmp/interceptor-runtime/node_modules/three/src/lights/AmbientLight.js
/tmp/interceptor-runtime/node_modules/three/src/lights/PointLight.js
/tmp/interceptor-runtime/node_modules/three/src/lights/DirectionalLightShadow.js
/tmp/interceptor-runtime/node_modules/three/src/lights/PointLightShadow.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Instance.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/AccessorsUtils.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/UserDataNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/ReferenceBaseNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/TextureSizeNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Normal.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/StorageBufferNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/ReferenceNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/ReferenceElementNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Arrays.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/StorageTextureNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/ReflectVector.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Texture3DNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/MaterialReferenceNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/CubeTextureNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/UV.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/MaterialNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Bitangent.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/BufferAttributeNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/UniformArrayNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Skinning.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/VelocityNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/ModelNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Batch.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Lights.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/TangentUtils.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Camera.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Object3DNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/TextureBicubic.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/ModelViewProjectionNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/MaterialProperties.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/BufferNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/VertexColorNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/BuiltinNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/SceneProperties.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/ClippingNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Morph.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/RendererReferenceNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/StorageTexture3DNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/TextureNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Tangent.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/Position.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/accessors/PointUVNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/TreeGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/ForestGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/TerrainGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/parse/MaterialXParser.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/MaterialXHextile.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/MaterialXArchive.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/materialx/MaterialXNodeInterfaceRegistry.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/LUT3dlLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/LUTCubeLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/OBJLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/DRACOLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/3MFLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/LWOLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/TIFFLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/offscreen/jank.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/extras/PMREMGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/offscreen/scene.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/offscreen/offscreen.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/xdg-open
/tmp/interceptor-runtime/node_modules/three/src/cameras/ArrayCamera.js
/tmp/interceptor-runtime/node_modules/three/src/cameras/CubeCamera.js
/tmp/interceptor-runtime/node_modules/three/src/cameras/OrthographicCamera.js
/tmp/interceptor-runtime/node_modules/three/src/cameras/Camera.js
/tmp/interceptor-runtime/node_modules/three/src/cameras/StereoCamera.js
/tmp/interceptor-runtime/node_modules/three/src/cameras/PerspectiveCamera.js
/tmp/interceptor-runtime/node_modules/three/src/utils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/QuadMesh.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Sampler.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/UniformsGroup.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/DirectRenderPipeline.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Color4.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/XRManager.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/BundleGroup.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/CubeRenderTarget.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RendererUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Bindings.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Textures.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Buffer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Storage3DTexture.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/ProgrammableStage.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderPipeline.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderBundles.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderObjectPipeline.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Pipelines.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderBundle.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/ReadbackBuffer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Attributes.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/recorder/assets/codicon-DCmgc-ay.ttf
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-DK72OMRi.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/recorder/assets/index-Cn0mmDuv.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/recorder/assets/index-BZpYJZ-P.css
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/recorder/playwright-logo.svg
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/lwo/LWO2Parser.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/lwo/LWO3Parser.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/lwo/IFFParser.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/UltraHDRLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/VOXLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/KMZLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/LUTImageLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/GLTFLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/KTXLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/PVRLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/USDLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/MaterialXLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/TGALoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/EXRLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/HDRCubeTextureLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/XYZLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/GaussianSplatPLYLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/StreetTreeGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/CityGeneratorUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/BenchGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/SidewalkGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/CarGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/SkyscraperGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/TrashcanGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/StreetlightGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/TrafficlightGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/HydrantGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/PersonGenerator.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/generators/city/InstancedMeshGenerator.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/StorageInstancedBufferAttribute.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Info.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Renderer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/IndirectStorageBufferAttribute.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/BufferUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/ComputePipeline.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Lighting.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/StorageBuffer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/ClippingContext.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderList.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Binding.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Animation.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Geometries.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Backend.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Pipeline.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/StorageBufferAttribute.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderContext.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/DataMap.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/RenderLists.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/XRRenderTarget.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/TimestampQueryPool.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/PostProcessing.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/Uniform.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/SampledTexture.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/usd/USDCParser.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/usd/USDComposer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/usd/USDAParser.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/SPZLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/IESLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/LDrawLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/BVHLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/PLYLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/FBXLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/DDSLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/HDRLoader.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/loaders/MD2Loader.js
/tmp/interceptor-runtime/node_modules/playwright-core/cli.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/nodes/NodeStorageBuffer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/nodes/NodeUniformBuffer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/nodes/NodeUniform.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/nodes/NodeSampledTexture.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/nodes/NodeUniformsGroup.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/nodes/NodeManager.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/nodes/NodeSampler.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/nodes/NodeBuilderState.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/nodes/NodeLibrary.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/common/StorageArrayTexture.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/pmrem/PMREMUtils.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/pmrem/PMREMNode.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/index.html
/tmp/interceptor-runtime/node_modules/playwright-core/lib/entry/cliDaemon.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/gpgpu/SubgroupFunctionNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/textures/FlakesTexture.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/exporters/DRACOExporter.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/exporters/KTX2Exporter.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/exporters/PLYExporter.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/exporters/OBJExporter.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/exporters/STLExporter.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/exporters/EXRExporter.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/exporters/USDZExporter.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/exporters/GLTFExporter.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/entry/oopBrowserDownload.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/entry/mcp.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/entry/dashboardApp.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/webp_codec.wasm
/tmp/interceptor-runtime/node_modules/playwright-core/types/protocol.d.ts
/tmp/interceptor-runtime/node_modules/playwright-core/types/structs.d.ts
/tmp/interceptor-runtime/node_modules/playwright-core/types/types.d.ts
/tmp/interceptor-runtime/node_modules/playwright-core/index.d.ts
/tmp/interceptor-runtime/node_modules/playwright-core/README.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/coreBundle.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/serverRegistry.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/WebGLBackend.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/NormalMapNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ScreenNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ToneMappingFunctions.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/PassNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ColorSpaceNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/PremultiplyAlphaFunctions.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ToneMappingNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/BlendModes.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ColorSpaceFunctions.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/RenderOutputNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ViewportSharedTextureNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ViewportTextureNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ViewportDepthTextureNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ViewportDepthNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ColorAdjustment.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/BumpMapNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/ToonOutlinePassNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/display/FrontFacingNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/gpgpu/AtomicFunctionNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/gpgpu/ComputeBuiltinNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/gpgpu/WorkgroupInfoNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/gpgpu/ComputeNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/gpgpu/BarrierNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/XRHandModelFactory.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/ARButton.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/Text2D.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/XRHandPrimitiveModel.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/OculusHandModel.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/WebGLXRFallback.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/XRHandMeshModel.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/XRButton.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/XRPlanes.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/OculusHandPointerModel.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/XRControllerModelFactory.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/XREstimatedLight.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/webxr/VRButton.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-nightly-Cp5nfeDT.svg
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/assets/codicon-DCmgc-ay.ttf
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-beta-k3eOH_eK.svg
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule--QdMvsKi.css
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/assets/index-AwgwcYie.css
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/assets/safari-na3_-uQk.svg
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/assets/index-hRvO4yU2.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule-CQ8RZGBm.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-1bWoP6pv.svg
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/dashboard/playwright-logo.svg
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/ProjectorLightNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/BasicLightMapNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/AmbientLightNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/DirectionalLightNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/EnvironmentNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/IrradianceNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/PointLightNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/LightingNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/SpotLightNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/LightUtils.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/HemisphereLightNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/ShadowBaseNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/LightingContextNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/ShadowNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/AONode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/IESSpotLightNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/BasicEnvironmentNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/LightProbeNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/ShadowFilterNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/AnalyticLightNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/RectAreaLightNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/LightsNode.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/lighting/PointShadowNode.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/SkeletonUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/SceneUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/GeometryCompressionUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/WorkerPool.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/GeometryUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/SortUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/BufferGeometryUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/ShadowMapViewerGPU.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/LDrawUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/SceneOptimizer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/WebGLTextureUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/ShadowMapViewer.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/WebGPUTextureUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/GaussianSplatUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/ColorUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/CameraUtils.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/utils/UVsDebug.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/utils/WebGLTimestampQueryPool.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/utils/WebGLUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/utils/WebGLExtensions.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/utils/WebGLConstants.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/utils/WebGLState.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/utils/WebGLCapabilities.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/utils/WebGLTextureUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/utils/WebGLAttributeUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/WebGLBufferRenderer.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/fog/Fog.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/htmlReport/index.html
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/htmlReport/report.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/vite/htmlReport/report.css
/tmp/interceptor-runtime/node_modules/playwright-core/lib/webp_codec.LICENSE
/tmp/interceptor-runtime/node_modules/playwright-core/lib/utilsBundle.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/environments/RoomEnvironment.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/environments/DebugEnvironment.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/environments/ColorEnvironment.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl-fallback/nodes/GLSLNodeBuilder.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/bootstrap.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/serverRegistry.js.LICENSE
/tmp/interceptor-runtime/node_modules/playwright-core/lib/utilsBundle.js.LICENSE
/tmp/interceptor-runtime/node_modules/playwright-core/lib/server/electron/loader.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/package.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/procedural/Checker.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/geometry/RangeNode.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/utils/extension.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/utils/socketConnection.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/parsers/GLSLNodeParser.js
/tmp/interceptor-runtime/node_modules/three/src/nodes/parsers/GLSLNodeFunction.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/cli-client/registry.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/cli-client/session.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/cli-client/output.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/cli-client/minimist.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/cli-client/program.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/cli-client/cli.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/cli-client/channelSessions.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/cli-client/help.json
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLProgram.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLAnimation.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLInfo.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLBackground.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLShader.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLRenderLists.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLUniforms.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLCapabilities.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLProperties.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLPrograms.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLUniformsGroups.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/dashboard/appIcon.png
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLBufferRenderer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLState.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLClipping.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLBindingStates.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLMorphtargets.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLEnvironments.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLRenderStates.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLUtils.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLAttributes.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLShadowMap.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLGeometries.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLTextures.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLLights.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLShaderCache.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLObjects.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLMaterials.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLExtensions.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLIndexedBufferRenderer.js
/tmp/interceptor-runtime/node_modules/three/src/renderers/webgl/WebGLOutput.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/server/chromium/appIcon.png
/tmp/interceptor-runtime/node_modules/three/examples/jsm/gpgpu/BitonicSort.js
/tmp/interceptor-runtime/node_modules/three/examples/jsm/gpgpu/CountingSort.js
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-trace/SKILL.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/typing.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/SKILL.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/session-management.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/storage-state.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/video-recording.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/playwright-tests.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/tracing.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/running-code.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/pr-attachments.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/request-mocking.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/test-generation.md
/tmp/interceptor-runtime/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/element-attributes.md
 o.data.set_sharp_from_angle(angle=math.radians(35))
 active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
print('GEOMETRY',triangles,'triangles',flush=True)

# Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.
world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world
nt=world.node_tree;nt.nodes.clear();N=nt.nodes;L=nt.links
out=N.new('ShaderNodeOutputWorld');bg=N.new('ShaderNodeBackground');bg.inputs['Strength'].default_value=.24
coord=N.new('ShaderNodeTexCoord');sep=N.new('ShaderNodeSeparateXYZ');L.new(coord.outputs['Normal'],sep.inputs[0]);r=N.new('ShaderNodeValToRGB');r.color_ramp.elements[0].position=0;r.color_ramp.elements[0].color=(.003,.006,.014,1);r.color_ramp.elements[1].position=1;r.color_ramp.elements[1].color=(.025,.043,.075,1);L.new(sep.outputs['Z'],r.inputs[0]);L.new(r.outputs[0],bg.inputs[0]);L.new(bg.outputs[0],out.inputs[0])
def area(name,loc,power,color,size,target=(0,0,0)):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.color=color;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler()
area('Upper front white key',(3,-8,12),2500,(1,.97,.92),7)
area('Cool soft fill',(-8,-1,6),1800,(.64,.78,1),8)
area('Engine rim',(1,9,8),2900,(.72,.83,1),6)
area('Soft belly fill',(3,-2,-6),700,(.66,.73,1),6)
sun=bpy.data.lights.new('Sunlight','SUN');sun.energy=1.2;sun.angle=.08;sun.color=(1,.98,.95);so=bpy.data.objects.new('Sunlight',sun);sc.collection.objects.link(so);so.rotation_euler=(.4,-.5,-.4)
camd=bpy.data.cameras.new('Evidence camera');cam=bpy.data.objects.new('Evidence camera',camd);sc.collection.objects.link(cam);sc.camera=cam
HERO=(15,-22,14)
def camera(loc,target=(0,0,.1),ortho=19.0):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=ortho
camera(HERO)
def render(path,w=1920,h=1080,samples=48):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.cycles.samples=samples;sc.render.filepath=str(path);bpy.ops.render.render(write_still=True)
# First a clay gate with all silhouette geometry visible.
clay=special('_Clay',(.32,.34,.36),.15,.42)
sc.view_layers[0].material_override=clay
render(ROOT/'clay.png',1920,1080,24)
sc.view_layers[0].material_override=None
# Save and export the first complete asset before final evidence renders.
paint=MATS['Paint'];pnt=paint.node_tree;bs=pnt.nodes.get('Principled BSDF');tint=pnt.nodes['Faction paint factor'];base=next(n for n in pnt.nodes if n.type=='TEX_IMAGE' and n.image.name=='Paint_Base')
pnt.links.new(base.outputs[0],bs.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in sc.objects:
 if o.type in ['MESH','EMPTY']:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'interceptor-hardsf.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
# Make the runtime paint and glass contracts explicit in the GLB JSON.
p=ROOT/'interceptor-hardsf.glb';raw=p.read_bytes();jl,jt=struct.unpack_from('<II',raw,12);g=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
for m in g['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.55,.56,.55,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
j=json.dumps(g,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
p.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4E4F534A)+j+binary)
pnt.links.new(tint.outputs[0],bs.inputs['Base Color'])
# Manifest uses glTF coordinates, not Blender coordinates.
def gltfpos(v):return [round(v.x,5),round(v.z,5),round(-v.y,5)]
pts=[o.matrix_world@Vector(v) for o in OBJECTS.values() for v in o.bound_box]
lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
PAINTS=[('Naval grey',[.55,.56,.55]),('Gunmetal',[.16,.185,.21]),('Olive drab',[.22,.27,.16]),('Desert sand',[.64,.52,.34]),('Arctic white',[.86,.88,.87]),('Deep navy',[.055,.105,.19])]
manifest={'asset':'interceptor-hardsf','units':'metres','forward':'+Z','up':'+Y','left':'+X','origin':'hull centre of mass estimate','nodes':{o.name:{'position':gltfpos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'socket'} for o in sc.objects if o.type in ['MESH','EMPTY']},'axes':{'Canopy':[-1,0,0],'Radiator_L':[0,0,1],'Radiator_R':[0,0,-1]},'bounding_box':{'min':[round(lo.x,5),round(lo.z,5),round(-hi.y,5)],'max':[round(hi.x,5),round(hi.z,5),round(-lo.y,5)],'size':[6.5,4.2,16.0]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'triangles':triangles,'materials':list(MATS)[:-1],'bakes':BAKE_LOG,'moving_part_test':{'degrees':30,'origin_drift_metres':0}}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join(['Original 16.0 × 6.5 × 4.2 m hard-SF tandem interceptor; hinged canopy, deployed radiators, railgun, four RCS quads and fusion bell.',f'{triangles:,} triangles; fixed Hull and three moving mesh nodes; Y up, +Z nose; metre scale.', '8 materials: Paint, Alloy, Heatshield, Radiator, Cockpit, Glass, Engines, Lamps.', 'Cycles baked: shared tiling colour/normal 1024 px, ORM 512 px; optical/emissive 64 px; neutral Paint texture × faction factor.', 'CC0 photographic sources: Poly Haven metal_plate and metal_plate_02; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/metal_plate_02 .','Reference differences: radiator cassettes are deployed; original cheek armour, twin-rail muzzle and engine framing; no exhaust plume or markings.'])+'\n')
(EVID/'bake_provenance.json').write_text(json.dumps(BAKE_LOG,indent=2))
bpy.ops.wm.save_as_mainfile(filepath=str(EVID/'interceptor.blend'))
print('FIRST_GLB_COMPLETE',round(time.time()-START,1),p.stat().st_size,flush=True)
render(ROOT/'final.png')
camera((20,0,.25),(0,0,.25),18.5);render(ROOT/'side.png',1920,1080,24)
camera((12,19,10),(0,1,0),18.0);render(EVID/'rear.png',1280,800,24)
camera((7,-10,6),(0,-1.7,1.1),7.7);render(EVID/'cockpit.png',1280,900,24)
# Explicit 30 degree test of every moving assembly around its own pivot, then restore.
camera(HERO)
for n in ['Canopy','Radiator_L','Radiator_R']:
 o=OBJECTS[n];before=o.matrix_world.translation.copy();o.rotation_euler[0 if n=='Canopy' else 1]=math.radians(30 if n=='Radiator_R' else -30)
 bpy.context.view_layer.update();assert (o.matrix_world.translation-before).length<1e-6
render(EVID/'pivot_test.png',1280,720,24)
for n in ['Canopy','Radiator_L','Radiator_R']:OBJECTS[n].rotation_euler=(0,0,0)
# Six-paint proof in a real rendered 3x2 contact sheet, no typography on the asset.
frames=[]
for name,c in PAINTS:
 tint.inputs[2].default_value=(*c,1);render(EVID/f'paint_{name.replace(" ","_")}.png',640,360,16)
 im=bpy.data.images.load(str(EVID/f'paint_{name.replace(" ","_")}.png'),check_existing=False);a=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(a);frames.append(a.reshape(360,640,4).copy());bpy.data.images.remove(im)
grid=np.concatenate([np.concatenate(frames[3:],axis=1),np.concatenate(frames[:3],axis=1)],axis=0)
im=bpy.data.images.new('Six faction paints',1920,720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
tint.inputs[2].default_value=(.55,.56,.55,1)
print('BUILD_COMPLETE',round(time.time()-START,1),triangles,p.stat().st_size,flush=True)

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 - <<'PY'
from pathlib import Path
import subprocess, shutil
root=Path.cwd(); ev=root/'evidence'; ev.mkdir(exist_ok=True)
remote='gex44:/root/fal-author/interceptor-hardsf-v1/'
for name in ['interceptor-hardsf.glb','final.png','side.png','clay.png','paints.png','nodes.json','NOTES.md']:
 subprocess.run(['scp',remote+name,str(root/name)],check=True)
for name in ['bake_provenance.json','build.log','pivot_test.png','cockpit.png','rear.png']:
 subprocess.run(['scp',remote+'evidence/'+name,str(ev/name)],check=True)
for src,dst in [('/tmp/interceptor-contract-final.json','contract_audit.json'),('/tmp/interceptor-audit-final.json','geometry_audit.json'),('/tmp/interceptor-critic-final.txt','visual_review.md')]:
 shutil.copy2(src,ev/dst)
for name in ['runtime.json','runtime.png','runtime_navy.png','runtime_normal_off.png','runtime_ao_off.png','runtime_maps_off.png']:
 shutil.copy2(Path('/tmp/interceptor-runtime')/name,ev/name)
print('All requested deliverables and final evidence copied.')
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc 'sha256sum build_interceptor-hardsf.py; ssh gex44 sha256sum /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py; df -h /' in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 562ms:
1d84788053f180dd4f6b84d91709787b9325e1401aa36f8f96e7c5fb16ea4a9e  build_interceptor-hardsf.py
1d84788053f180dd4f6b84d91709787b9325e1401aa36f8f96e7c5fb16ea4a9e  /root/fal-author/interceptor-hardsf-v1/build_interceptor-hardsf.py
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1       150G  138G  6.5G  96% /

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

 succeeded in 12318ms:
All requested deliverables and final evidence copied.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 - <<'PY'
from pathlib import Path
import json, struct, hashlib, math
from PIL import Image
import numpy as np
root=Path.cwd(); raw=(root/'interceptor-hardsf.glb').read_bytes(); jl=struct.unpack_from('<I',raw,12)[0]; g=json.loads(raw[20:20+jl]); m=json.loads((root/'nodes.json').read_text()); parents={c:i for i,n in enumerate(g['nodes']) for c in n.get('children',[])}
def matrix(i):
 n=g['nodes'][i]
 if 'matrix' in n: local=np.array(n['matrix']).reshape(4,4).T
 else:
  x,y,z,w=n.get('rotation',[0,0,0,1]); R=np.array([[1-2*(y*y+z*z),2*(x*y-z*w),2*(x*z+y*w)],[2*(x*y+z*w),1-2*(x*x+z*z),2*(y*z-x*w)],[2*(x*z-y*w),2*(y*z+x*w),1-2*(x*x+y*y)]])
  local=np.eye(4);local[:3,:3]=R@np.diag(n.get('scale',[1,1,1]));local[:3,3]=n.get('translation',[0,0,0])
 return matrix(parents[i])@local if i in parents else local
errs=[]
for i,n in enumerate(g['nodes']):
 pos=matrix(i)[:3,3]; entry=m['nodes'][n['name']]; delta=float(np.max(np.abs(pos-np.array(entry['position']))));
 if delta>1e-4:errs.append([n['name'],delta])
 assert entry['parent']==(g['nodes'][parents[i]]['name'] if i in parents else None)
assert not errs,errs
assert len(m['nodes'])==len(g['nodes'])==30
assert len((root/'NOTES.md').read_text().splitlines())==6
assert len(m['paints'])==6
assert m['paints'][0]['linear_rgb']==[.55,.56,.55]
assert set(m['axes'])=={'Canopy','Radiator_L','Radiator_R'}
for axis in m['axes'].values(): assert abs(sum(x*x for x in axis)-1)<1e-8
runtime=json.loads((root/'evidence/runtime.json').read_text())
assert max(abs(a-b) for a,b in zip(runtime['bounds']['size'],[6.5,4.2,16]))<2e-5
assert max(abs(a-b) for key in ['min','max'] for a,b in zip(runtime['bounds'][key],m['bounding_box'][key]))<2e-5
out={'verdict':'PASS','node_count':30,'manifest_world_positions_match_glb':True,'dimensions_xyz_metres':runtime['bounds']['size'],'notes_lines':6,'script_sha256':hashlib.sha256((root/'build_interceptor-hardsf.py').read_bytes()).hexdigest(),'glb_sha256':hashlib.sha256(raw).hexdigest(),'renders':{}}
for name in ['final.png','side.png','clay.png','paints.png']:
 with Image.open(root/name) as im: size=im.size;im.verify()
 out['renders'][name]=list(size)
 assert size==((1920,720) if name=='paints.png' else (1920,1080))
(root/'evidence/delivery_check.json').write_text(json.dumps(out,indent=2)+'\\n')
print(json.dumps(out,indent=2))
print((root/'NOTES.md').read_text())
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 202ms:
{
  "verdict": "PASS",
  "node_count": 30,
  "manifest_world_positions_match_glb": true,
  "dimensions_xyz_metres": [
    6.4999998807907104,
    4.199999809265137,
    15.999999046325684
  ],
  "notes_lines": 6,
  "script_sha256": "1d84788053f180dd4f6b84d91709787b9325e1401aa36f8f96e7c5fb16ea4a9e",
  "glb_sha256": "4e553f969f52a7d0b57a30f06d00bd9a0c91b787315986d76d87807d5959b7e1",
  "renders": {
    "final.png": [
      1920,
      1080
    ],
    "side.png": [
      1920,
      1080
    ],
    "clay.png": [
      1920,
      1080
    ],
    "paints.png": [
      1920,
      720
    ]
  }
}
Original 16.0 × 6.5 × 4.2 m hard-SF tandem interceptor; hinged canopy, deployed radiators, railgun, four RCS quads and fusion bell.
87,989 triangles; fixed Hull and three moving mesh nodes; Y up, +Z nose; metre scale.
8 materials: Paint, Alloy, Heatshield, Radiator, Cockpit, Glass, Engines, Lamps.
Cycles baked: shared tiling colour/normal 1024 px, ORM 512 px; optical/emissive 64 px; neutral Paint texture × faction factor.
CC0 photographic sources: Poly Haven metal_plate and metal_plate_02; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/metal_plate_02 .
Reference differences: radiator cassettes are deployed; original cheek armour, twin-rail muzzle and engine framing; no exhaust plume or markings.


diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "sed -n '1,80p' build_interceptor-hardsf.py; rg -n 'seed|ROOT|GPU|OPTIX|CUDA|bake|remove' build_interceptor-hardsf.py | head -35; du -sh evidence; rg --files -g '"'!__pycache__/*'"' -g '"'!evidence/*'"' ." in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
"""Forge hard-SF interceptor. Blender 5.2, clean-scene build, CC0 tile bakes, GLB and evidence renders.
Run: blender -b --python-exit-code 1 --python build_interceptor-hardsf.py
Coordinates during authoring: X left, -Y forward, Z up. Export converts to X left, Y up, Z forward.
Scope: 16 x 6.5 x 4.2m; 40–90k tris; <=9 mats; 1024 colour/normal, 512 ORM; <=12MB.
Views: hero front-left, ortho side, clay, six paints, 30-degree hinge test. Revision cap: 3.
"""
import bpy, math, os, sys, json, urllib.request, subprocess, struct, time
import numpy as np
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
EVID=ROOT/'evidence'; EVID.mkdir(exist_ok=True)
START=time.time()
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene
sc.unit_settings.system='METRIC';sc.unit_settings.scale_length=1
sc.render.engine='CYCLES';sc.cycles.device='GPU';sc.cycles.samples=48
prefs=bpy.context.preferences.addons['cycles'].preferences
for backend in ['OPTIX','CUDA']:
 try:
  prefs.compute_device_type=backend;prefs.get_devices()
  if any(d.type==backend for d in prefs.devices):
   for d in prefs.devices:d.use=d.type==backend
   break
 except Exception:pass
else:raise RuntimeError('CUDA/OptiX GPU required')
sc.cycles.use_denoising=True;sc.render.image_settings.file_format='PNG'
sc.view_settings.view_transform='AgX'
sc.render.resolution_percentage=100
sc.render.film_transparent=False
GROUPS={'Hull':[],'Canopy':[],'Radiator_L':[],'Radiator_R':[]}
MATS={}; BAKE_LOG=[]

def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def uv_project(o):
 uv=o.data.uv_layers.new(name='UVMap') if not o.data.uv_layers else o.data.uv_layers.active
 for f in o.data.polygons:
  n=f.normal;axis=max(range(3),key=lambda i:abs(n[i]));a,b=([1,2] if axis==0 else [0,2] if axis==1 else [0,1])
  for li in f.loop_indices:
   p=o.data.vertices[o.data.loops[li].vertex_index].co
   uv.data[li].uv=(p[a]*.57,p[b]*.57)

def finish(o,name,mat,group='Hull',bevel=0,smooth=False):
 o.name=name
 if mat:o.data.materials.append(MATS[mat] if isinstance(mat,str) else mat)
 if bevel:
  active(o);m=o.modifiers.new('Machined edge radii','BEVEL');m.width=bevel;m.segments=2
  bpy.ops.object.modifier_apply(modifier=m.name)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 else:
  # Weighted face normals preserve armour planes while letting chamfers catch highlights.
  active(o)
  if bevel:
   for p in o.data.polygons:p.use_smooth=True
   m=o.modifiers.new('Weighted normals','WEIGHTED_NORMAL');m.keep_sharp=True;m.weight=50
   bpy.ops.object.modifier_apply(modifier=m.name)
 uv_project(o)
 if mat=='Lamps':
  for uv in o.data.uv_layers.active.data:uv.uv=(.125,.5)
 if group:GROUPS[group].append(o)
 return o

def mesh(name,verts,faces,mat,group='Hull',bevel=0,smooth=False):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 o=bpy.data.objects.new(name,me);sc.collection.objects.link(o)
 # Make connected surfaces outward-facing.
 import bmesh
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 return finish(o,name,mat,group,bevel,smooth)

def box(name,loc,size,mat='Paint',bevel=.025,group='Hull'):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object
 o.data.transform(Matrix.Diagonal(Vector((*size,1))));return finish(o,name,mat,group,bevel)

def sphere(name,loc,scale,mat='Alloy',group='Hull',segs=32,rings=16):
1:"""Forge hard-SF interceptor. Blender 5.2, clean-scene build, CC0 tile bakes, GLB and evidence renders.
12:ROOT=Path(__file__).resolve().parent
13:TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
14:EVID=ROOT/'evidence'; EVID.mkdir(exist_ok=True)
19:sc.render.engine='CYCLES';sc.cycles.device='GPU';sc.cycles.samples=48
21:for backend in ['OPTIX','CUDA']:
28:else:raise RuntimeError('CUDA/OptiX GPU required')
138:def bake_material(name,color,metal,rough,asset='metal_plate',relief=.1,neutral=False):
162: bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,0));o=bpy.context.object;o.name='Tile bake fixture';o.data.materials.append(m)
165: baked={}
173:  active(o);sc.cycles.samples=8;sc.render.bake.margin=8;bpy.ops.object.bake(type=typ)
174:  im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();baked[kind]=im
175: bpy.data.objects.remove(o,do_unlink=True)
178:  n=N.new('ShaderNodeTexImage');n.image=baked[kind];n.label='Cycles baked shared tile'
192: MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
195:for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
202: # Even constant glass and emissive finishes are Cycles baked for portable appearance.
214:  baked={}
223:     for link in list(emit.inputs['Color'].links):L.remove(link)
226:   active(o);sc.cycles.samples=4;bpy.ops.object.bake(type=typ);im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();baked[kind]=im
227:  bpy.data.objects.remove(o,do_unlink=True);L.new(bs.outputs[0],out.inputs[0])
228:  for kind,im in baked.items():
296: bpy.data.objects.remove(cutter,do_unlink=True)
602:render(ROOT/'clay.png',1920,1080,24)
610:bpy.ops.export_scene.gltf(filepath=str(ROOT/'interceptor-hardsf.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
612:p=ROOT/'interceptor-hardsf.glb';raw=p.read_bytes();jl,jt=struct.unpack_from('<II',raw,12);g=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
624:manifest={'asset':'interceptor-hardsf','units':'metres','forward':'+Z','up':'+Y','left':'+X','origin':'hull centre of mass estimate','nodes':{o.name:{'position':gltfpos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'socket'} for o in sc.objects if o.type in ['MESH','EMPTY']},'axes':{'Canopy':[-1,0,0],'Radiator_L':[0,0,1],'Radiator_R':[0,0,-1]},'bounding_box':{'min':[round(lo.x,5),round(lo.z,5),round(-hi.y,5)],'max':[round(hi.x,5),round(hi.z,5),round(-lo.y,5)],'size':[6.5,4.2,16.0]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'triangles':triangles,'materials':list(MATS)[:-1],'bakes':BAKE_LOG,'moving_part_test':{'degrees':30,'origin_drift_metres':0}}
625:(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
626:(ROOT/'NOTES.md').write_text('\n'.join(['Original 16.0 × 6.5 × 4.2 m hard-SF tandem interceptor; hinged canopy, deployed radiators, railgun, four RCS quads and fusion bell.',f'{triangles:,} triangles; fixed Hull and three moving mesh nodes; Y up, +Z nose; metre scale.', '8 materials: Paint, Alloy, Heatshield, Radiator, Cockpit, Glass, Engines, Lamps.', 'Cycles baked: shared tiling colour/normal 1024 px, ORM 512 px; optical/emissive 64 px; neutral Paint texture × faction factor.', 'CC0 photographic sources: Poly Haven metal_plate and metal_plate_02; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/metal_plate_02 .','Reference differences: radiator cassettes are deployed; original cheek armour, twin-rail muzzle and engine framing; no exhaust plume or markings.'])+'\n')
627:(EVID/'bake_provenance.json').write_text(json.dumps(BAKE_LOG,indent=2))
630:render(ROOT/'final.png')
631:camera((20,0,.25),(0,0,.25),18.5);render(ROOT/'side.png',1920,1080,24)
645: im=bpy.data.images.load(str(EVID/f'paint_{name.replace(" ","_")}.png'),check_existing=False);a=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(a);frames.append(a.reshape(360,640,4).copy());bpy.data.images.remove(im)
647:im=bpy.data.images.new('Six faction paints',1920,720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
5.7M	evidence
./NOTES.md
./build_interceptor-hardsf.py
./started.txt
./sheet.jpg
./nodes.json
./PROMPT.txt
./sheet_prompt.txt
./job.json

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

codex
All requested files are now in the job directory. The exported model measures 16.0 × 6.5 × 4.2 m, and all 30 node positions match `nodes.json`. The independent visual review passed; its optional refinements are recorded in `evidence/visual_review.md`.

The local disk has about 6.5 GB free. I’m removing the temporary tools I installed for validation.
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "rg -n -A 14 -B 3 'handoff|deliver|evidence|Stop|receipt|PASS|release' /home/claude-agent/.agents/skills/blender-game-assets/SKILL.md" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
30-| --- | --- |
31-| A dense model must become a light one that keeps its look (normal + AO transfer) | [high-to-low baking](reference/high-to-low-baking.md) |
32-| Preparing UVs or a cage, or a bake shows seams, black faces, wrong highlights | [baking and diagnostics](reference/baking-and-diagnostics.md) |
33:| Choosing texture or mesh formats, or the asset is slow to load or heavy in GPU memory (KTX2, WebP, meshopt, Draco) | [asset delivery](reference/asset-delivery.md) |
34-| Before any long bake or scripted export pipeline | [preflight and recovery](reference/preflight-and-recovery.md) |
35-| The asset looks right in Blender and wrong in the game, or the cause could be either | [runtime parity and diagnostics](reference/runtime-parity-and-diagnostics.md) |
36-| The asset will be judged by eye (hero prop, character, building, environment) | [author and critic loop](reference/critic-loop.md) |
37-| Terrain, cliffs or rocks should look weathered (branching gullies and ridges, no simulation) | [terrain erosion](reference/terrain-erosion.md) |
38-
39-These seven are loaded on demand, not by default. Five are ported from vercel-labs/vgpu (see Attribution); terrain erosion uses Rune Skovbo Johansen's filter (MPL 2.0, `scripts/erosion/`).
40-
41-### Choose how to carry each detail before any bake
42-
43-| Detail | Carry it as |
44-| --- | --- |
45-| Silhouette, openings, broad edges, tuft dimples, piping, moving parts | Real low-poly geometry |
46-| Fine bevel curvature and shallow relief the triangle budget cannot afford | Tangent-space normals baked from the dense source |
47-| Permanent cavities and contacts inside a rigid assembly | Baked AO |
--
491-Chimala). Runnable reference code copied under the same license into
492-`reference/` — see `reference/INDEX.md`.
493-
494:`reference/high-to-low-baking.md`, `baking-and-diagnostics.md`, `asset-delivery.md`,
495-`preflight-and-recovery.md` and `runtime-parity-and-diagnostics.md` are ported from
496-vercel-labs/vgpu `skills/vgpu/blender/references/` at commit 68cb1d8d198c (MIT License,
497-Copyright (c) 2025 Vercel, Inc.), adapted from vgpu/WebGPU to the three.js runtime.
498-`reference/critic-loop.md` is distilled from the same repository's asset agent flow.

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "python3 - <<'PY'
from pathlib import Path
import shutil,json
root=Path.cwd()
shutil.rmtree(root/'__pycache__',ignore_errors=True)
shutil.rmtree(Path('/tmp/interceptor-runtime/node_modules'),ignore_errors=True)
print('\\n'.join(f'{p.name}: {p.stat().st_size:,} bytes' for p in sorted(root.iterdir()) if p.is_file() and p.name in ['interceptor-hardsf.glb','build_interceptor-hardsf.py','final.png','side.png','clay.png','paints.png','nodes.json','NOTES.md']))
PY" in /home/claude-agent/forge-build/forge-assets/jobs/interceptor-hardsf-v1
 succeeded in 0ms:
NOTES.md: 755 bytes
build_interceptor-hardsf.py: 43,532 bytes
clay.png: 2,338,339 bytes
final.png: 2,497,644 bytes
interceptor-hardsf.glb: 8,782,724 bytes
nodes.json: 8,176 bytes
paints.png: 1,473,028 bytes
side.png: 2,040,659 bytes

diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

hook: PreToolUse
hook: PreToolUse Completed
mcp: forge/coord_release started
mcp: forge/coord_release (completed)
hook: PreToolUse
hook: PreToolUse Completed
mcp: forge/coord_release started
mcp: forge/coord_release (completed)
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

codex
Delivered all requested files:

- [GLB asset](interceptor-hardsf.glb) — 87,989 triangles, 8 materials, 8.78 MB.
- [Reproducible Blender script](build_interceptor-hardsf.py).
- Renders: [final](final.png), [side](side.png), [clay](clay.png), [six paints](paints.png).
- [Node manifest](nodes.json) and [notes/licences](NOTES.md).

Verified dimensions, 30° moving-part pivots, recolouring, texture limits, and Three.js loading. Independent visual review passed.

Local disk space remaining: approximately 6.5 GB.
diff --git a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
index 2eebc493ddb350bd37dd04ec90ebf44188e4d92b..4e1371d3548d965af8c71da7ab0fde3f92044754
--- a/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
+++ b/jobs/interceptor-hardsf-v1/build_interceptor-hardsf.py
@@ -88,6 +88,12 @@
  o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
  return finish(o,name,mat,group,bevel,True)
 
+def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
+ a,b=Vector(a),Vector(b);d=b-a
+ o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
+ o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
+ return o
+
 def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
  bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
  o=bpy.context.object
@@ -136,13 +142,14 @@
  def tex(k):
   n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
  diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
- ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.10;ramp.color_ramp.elements[1].position=.8
- low=.58 if neutral else .62;high=.94 if neutral else 1.15
+ ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
+ low=.52 if neutral else .62;high=.98 if neutral else 1.15
  ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
  L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Heatshield':
-  ramp.color_ramp.elements[0].color=(.045,.026,.017,1);ramp.color_ramp.elements[1].color=(.14,.18,.25,1)
+  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
+  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)
 
  noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
  pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
@@ -185,7 +192,7 @@
  MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
  print('BAKED',name,flush=True)
 
-for args in [('Paint',(1,1,1),.42,.46,'metal_plate',.08,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate',.18,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
+for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)
 
 def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
@@ -229,12 +236,12 @@
     sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
  MATS[name]=m;return m
-special('Glass' ,(.19,.30,.36),.18,.14,.30)
+special('Glass' ,(.12,.22,.28),.20,.12,.24)
 special('Engines',(.12,.28,.65),.45,.26,1,1.8)
 special('Lamps',(.21,.64,.72),.25,.25,1,2)
 
 # Main pressure/armour shell; longitudinal stations define the silhouette.
-SECTIONS=[(-6.10,.40,-.44,.24),(-5.35,.72,-.65,.63),(-4.25,1.13,-.82,.86),(-2.9,1.55,-.91,1.01),(-1.1,2.02,-1.0,1.13),(.7,2.23,-1.05,1.18),(2.8,2.22,-1.05,1.15),(4.42,1.95,-.91,1.02)]
+SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
 def ring(y,w,bot,top):
  return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
 vs=[]
@@ -246,9 +253,12 @@
   fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs += [tuple(reversed(range(8))),tuple(range(56,64))]
 hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
+box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
+box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
+box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
 # Low armoured dorsal spine behind the cockpit.
 vs=[]
-for s in [(0.65,.83,1.02,2.28),(1.5,.88,1.02,2.25),(3.6,.8,.99,1.91),(4.45,.67,.83,1.43)]:vs+=ring(*s)
+for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
 fs=[tuple(reversed(range(8))),tuple(range(24,32))]
 for k in range(3):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
@@ -268,22 +278,35 @@
  # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
  for y in [.15,1.25,2.5,3.65]:
   x=sign*1.39
-  box('Flush maintenance gasket',(x,y,1.16),(.56,.63,.065),'Cockpit',.065)
-  box('Armoured maintenance cover',(x,y,1.205),(.51,.58,.055),'Paint',.055)
+  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
+  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
+  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
   for xx in [-.17,.17]:
-   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.241),'Z',r=.018)
+   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
  # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
- vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.65),(1.7,-2.5,.98),(2.06,-1.0,1.08),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.58),(1.58,-2.5,.85),(1.94,-1.0,.93),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
+ vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
  ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
  cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
  # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
  cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
  for target in [hull_shell,cheek]:
   active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
+  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
+  for p in target.data.polygons:p.use_smooth=False
  bpy.data.objects.remove(cutter,do_unlink=True)
  box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
  for j in range(8):
   box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
+ # Four substantial sloping walls lead into a pressure-sealed recess.
+ corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
+ wall=[]
+ for depth in [0,1]:
+  for y,z in corners:
+   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
+   wall.append((sign*x,y,z))
+ lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
+ active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  # Framed opening and surrounding armour remain continuous.
  for y in [-2.54,-1.20]:
   x=sign*(1.62 if y<-2 else 1.95)
@@ -320,7 +343,7 @@
  for z in [-.48,-.61]:
   tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
   for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
-for y,w,z in [(1.57,.85,2.25),(3.48,.80,1.94)]:
+for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
  tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
  box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
  for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)
@@ -328,14 +351,17 @@
 # Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
 for sx in [-1,1]:
  for sz in [-1,1]:
-  x=sx*1.46;z=1.39 if sz==1 else -1.06
+  x=sx*1.46;z=1.20 if sz==1 else -1.06
   cap=[]
   for j in range(13):
    a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
   cap.append((3.52,.49))
   for j in range(1,13):
    a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
-  lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
+  tank.data.materials.append(MATS['Heatshield'])
+  for p in tank.data.polygons:
+   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1
 
   for y in [1.28,3.52]:
    torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
@@ -346,7 +372,7 @@
   torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
 # Dorsal antenna/sensor covers (no markings).
 for y in [1.35,3.5]:
- sphere('Recessed dorsal sensor',(0,y,2.20 if y<2 else 1.94),(.36,.43,.14),'Alloy',segs=24,rings=12)
+ sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)
 
 # Cockpit tub open beneath the transparent hinged canopy.
 cockpit_start=len(GROUPS['Hull'])
@@ -366,9 +392,9 @@
  # Instrument panel face sits ahead of each operator.
  box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
  for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
-for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.24
+for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
 # Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
-CAN=[(-3.95,.43,1.00,1.13),(-3.0,.66,1.08,1.75),(-1.75,.77,1.14,2.13),(-.50,.79,1.18,2.28),(.72,.77,1.20,2.28)]
+CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
 # Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
 def interp(y,rows):
  if y<=rows[0][0]:return rows[0][1:]
@@ -386,8 +412,11 @@
    vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
   o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
   active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
+  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
  for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)
 
+# Closed forward pressure sill bridges the central triangle before the windscreen.
+mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
 # each station left sill, left roof, right roof, right sill
 cr=[]
 for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
@@ -399,14 +428,14 @@
   active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
 for j in range(4):
  for k in range(4):
-  tube('Canopy seal',cr[k][j],cr[k+1][j],.055,'Cockpit',12,'Canopy')
-  tube('Canopy longitudinal frame',cr[k][j],cr[k+1][j],.037,'Paint',12,'Canopy')
+  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
+  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
 for k in range(5):
- for j in range(3):tube('Armoured canopy bow',cr[k][j],cr[k][j+1],.052,'Paint',12,'Canopy')
+ for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
  for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
 for s in [-1,1]:
- tube('Canopy hinge trunnion',(s*.59,.77,1.27),(s*.82,.77,1.27),.10,'Alloy',24)
- tube('Canopy rotary servo',(s*.63,.77,1.27),(s*.88,.77,1.27),.14,'Heatshield',24)
+ tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
+ tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)
 
 # Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
 lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
@@ -437,13 +466,18 @@
    start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
    lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
    torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
+   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
    for o in GROUPS['Hull'][start:]:
     o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
-   RCS.append(p+d*.24)
+   RCS.append((p+d*.24,d))
 # Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
 box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
 box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
 for s in [-1,1]:
+ fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
+ active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
+ bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
+for s in [-1,1]:
  box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
  for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
  tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
@@ -473,12 +507,13 @@
  nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
  for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
  box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
-beacon=sphere('Dorsal beacon',(0,2.24,2.20),(.09,.15,.08),'Lamps',segs=16,rings=8)
+box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
+beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
 for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)
 
 # Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
 # never apply transforms to these moving nodes.
-PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.27),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
+PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
 OBJECTS={}
 for name,objs in GROUPS.items():
  bpy.ops.object.select_all(action='DESELECT')
@@ -496,11 +531,12 @@
 def socket(name,p,parent='Hull'):
  o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
 socket('Thruster_1',(0,8,0))
-for i,p in enumerate(RCS,2):socket(f'Thruster_{i}',p)
+for i,(p,d) in enumerate(RCS,2):
+ o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
 socket('Muzzle_1',(0,-8.02,-.64))
 for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
-socket('Seat_Pilot',(0,-2.25,1.05));socket('Seat_Copilot',(0,-.65,1.05));socket('Camera_Chase',(0,12,5.3))
-socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,2.27))
+socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
+socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
 # Exact overall dimensions: apply a single coordinate correction to data and node locations,
 # not object transforms, preserving all hinge frames.
 bpy.context.view_layer.update()
@@ -519,6 +555,26 @@
  for o in OBJECTS.values():
   active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
  triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
+# Final millimetre correction after reduction; moving pivots keep identity scale.
+bpy.context.view_layer.update()
+pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
+lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
+F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
+for o in list(sc.objects):
+ if o.type=='MESH':o.data.transform(FM)
+ o.location=Vector([o.location[i]*F[i] for i in range(3)])
+bpy.context.view_layer.update()
+# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
+# use area-weighted normals; smooth round components retain their continuous highlights.
+for o in OBJECTS.values():
+ import bmesh
+ bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
+ if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
+ bm.to_mesh(o.data);bm.free()
+ for p in o.data.polygons:p.use_smooth=True
+ o.data.set_sharp_from_angle(angle=math.radians(35))
+ active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
+triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
 print('GEOMETRY',triangles,'triangles',flush=True)
 
 # Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.

tokens used
299,508
Delivered all requested files:

- [GLB asset](interceptor-hardsf.glb) — 87,989 triangles, 8 materials, 8.78 MB.
- [Reproducible Blender script](build_interceptor-hardsf.py).
- Renders: [final](final.png), [side](side.png), [clay](clay.png), [six paints](paints.png).
- [Node manifest](nodes.json) and [notes/licences](NOTES.md).

Verified dimensions, 30° moving-part pivots, recolouring, texture limits, and Three.js loading. Independent visual review passed.

Local disk space remaining: approximately 6.5 GB.
