--- a/forge_engine/pipeline/fast/fast_build.py
+++ b/forge_engine/pipeline/fast/fast_build.py
@@ -1706,6 +1706,7 @@
 - **Photo enhance, looks, logo to SVG, PDF print check** (deterministic, our own CPU service, keeps the user's REAL pixels): `model='forge:craft-v1'` via `openai.images.edit(model='forge:craft-v1', image=<bytes>, prompt='<op>')` (+ optional form field `size` = long edge px). Ops: `enhance` (auto tone + straighten, 1 JPEG); `look:<id>` (1 JPEG, full size); `looks:<id>,<id>,...` (max 12, one small JPEG per id in order: a filter-picker row in ONE call, `size` default 480); `trace:6` (logo/icon/flat art PNG -> 1 SVG; `trace:3`/`trace:16` fewer/more colours); `preflight:150` (upload a PDF <= 25 MB -> 1 JSON report: ppi per image, fonts embedded, boxes). Look ids include lc.warm-glow, lc.golden-hour, lc.bright-airy, lc.soft-pastel, lc.warm-film, lc.faded-matte, lc.cinematic, lc.bw-high-contrast, lc.vivid-pop, lc.teal-orange, lc.moody, lc.neon-night (unknown id -> 400). Reply: `data[i].b64_json` = output bytes; `data[i].revised_prompt` = JSON facts per item (look `id`). The right model for 'enhance/auto-fix my photo', photo filters/presets, 'vectorize my logo', print-ready checks. Not for content changes (`forge:image-edit`) or cutouts (`forge:bg-remove`).
 - **Text-to-speech + the user's OWN cloned voice** (self-hosted, French/English/German/Spanish/Italian/Portuguese): `openai.audio.speech.create(model='forge:tts-v1', voice='house:fr-female'|'house:en-female'|'house:en-male', input=text, response_format='mp3')` server-side (first call after idle can take ~30 s while the GPU model starts: 120 s timeout + a progress state). CLONING is consent-gated and never through the SDK: server-side fetch to base = `LITELLM_BASE_URL` with its trailing `/v1` replaced by `/tts/v1`, header `Authorization: Bearer ${LITELLM_API_KEY}`: (1) `POST {base}/voice/consent/challenge` {user, display_name, language} -> {challenge_id, text}; (2) show `text`, record the user reading it aloud LIVE in the browser (MediaRecorder, 4-30 s, one take; NEVER accept an uploaded file) with two explicit checkboxes (I am 18 or older / this is my own voice and I agree it is copied); (3) `POST {base}/voice/consent/enroll` multipart {challenge_id, user, audio, attest_adult:'true', attest_own_voice:'true', client_ip} -> {voice_id} or 422 {reason, heard} (show the reason, let them record again); store voice_id server-side on that user only; (4) `POST {base}/audio/speech` {input, voice: voice_id, user, response_format:'mp3'}; (5) a 'Delete my voice' button -> `DELETE {base}/voice/voices/{voice_id}?user=`. Label cloned audio 'AI copy of your voice'. Never clone another person's voice or a clip; the service refuses it.
 - **3D mesh generation** (text → GLB): `model='forge:3d'` via `openai.images.generate(model='forge:3d', prompt='...', n=1)`. ~3min background job — NEVER inline; async with 3-4× retry on 429/5xx. Response: `data[0].url` = hosted .glb; `data[0].preview_url` = ready-made PNG thumbnail (optional) — use it for cards/galleries instead of rendering the mesh client-side. Interactive display: `<model-viewer src={url} auto-rotate camera-controls></model-viewer>` (Google web component, CDN-loaded) or a minimal Three.js `GLTFLoader` viewer.
+- **Forge 3D library — check it BEFORE generating a mesh for a common object** (furniture, crates and barrels, lamps, benches, trees and rocks, houses and wall/roof modules, cars and bikes, ships): our own approved, phone-sized GLBs, free and instant (a `forge:3d` mesh is a 2-4 minute GPU job). Search: `python3 /app/forge_engine/tools/library_search_cli.py "wardrobe" "street lantern" --kind prop.` (one quoted query per object; `--kind` is an id prefix such as `prop.`, `building.`, `vehicle.`; it prints `id | name | size in metres | triangles | KB | summary`). A line `GAP:` means the library has nothing for that object: generate or build it as usual, never use a lookalike. To use a hit, declare it in `.forge/library-manifest.json` (bare JSON array, 16 entries max): `[{"id": "prop.furniture.wardrobe/store-wardrobe-paxa", "path": "generated/library/wardrobe.glb"}]`; the engine copies the verified file to `public/generated/library/` and writes `.forge/library-attributions.json`; load it with `GLTFLoader` from the LOCAL path `/generated/library/wardrobe.glb` (never an https URL, same lighting rules as any GLB). The file may not exist while you build: load it defensively and keep a primitive stand-in. Scale from the printed metre size. A library hit never replaces the hero mesh rule below, and a game still declares its own concept-sheet props when the library has none.
 - **Displaying a GLB in Three.js — three traps that ship BLACK or washed-out thumbnails.** (1) **No material means METAL.** `forge:3d` meshes often carry no material; glTF's default is metalness 1, which renders BLACK under punctual lights alone (more lights does NOT fix it). ALWAYS add an environment map: `scene.environment = new THREE.PMREMGenerator(gl).fromScene(new RoomEnvironment(), 0.04).texture` from `three/examples/jsm/environments/RoomEnvironment.js` (built into three, no network/asset), and demote every `MeshStandardMaterial` with no `map`/`metalnessMap` to metalness 0.05 / roughness 0.75, mid-grey base colour. Tune exposure so the mesh lands mid-grey (~170/255), not near-white — blown-out is as invisible on a light card as black is on dark. (2) **Capture from the CANVAS, never a render target.** three tone-maps and does linear-to-sRGB ONLY on the default framebuffer, so a `WebGLRenderTarget` + `readRenderTargetPixels` thumbnail comes back ~2x too dark. Capture with `gl.render(scene, cam)` then `gl.domElement.toDataURL('image/png')`, `preserveDrawingBuffer: true`; hide grids/helpers, null the scene background so the thumbnail carries alpha. (3) **`THREE.Color` cannot parse `oklch()` or `color-mix()`** — warns to console, silently stays WHITE. Every colour handed to three (background, lights, materials, drei `Grid`) MUST be a hex literal.
 - **Image → procedural 3D** (reference image → code-only Three.js): reconstruct an object IN TYPESCRIPT instead of loading a mesh — `three` primitives (Box/Cylinder/Lathe/Extrude/Tube), procedural + canvas-generated materials, instancing. NO `.glb`, no binary asset, no loader, no CDN — diffable source; `three` + `@types/three` are auto-provisioned. **Use for**: stylised game props, isometric dioramas, playful/branded hero objects, low-poly scene furniture. **Do NOT use for**: real consumer-product viewers/packshots compared against the actual product — use `forge:3d` or a photograph instead. Build in passes (silhouette blockout → structure → materials → lighting), never one shot. ⚠️ **SCOPE GUARD — expensive**: a faithful object is a 20-30 minute background job costing roughly what a whole app build costs. Do NOT attempt during an autonomous build unless the brief EXPLICITLY asks for a bespoke 3D object; use `forge:image` for routine visuals, `forge:3d` for a solid/printable mesh. Every WebGL mount MUST follow the graceful-degradation rule above.
 - **Parametric CAD** (exact dimensioned parts for 3D printing): `POST https://infer.ohmyunicorn.com/v1/cad/generate` with `{source: '<python defining gen_step(**params) -> build123d Shape>', params: {...}, exports: ['step','stl','3mf']}` (plain httpx/fetch, IP-allowlisted — NOT the LiteLLM gateway, no bearer token). ~1.9s end-to-end — call inline from a request handler with a spinner, NOT a background job like forge:3d. Response: `{ok, artifacts: {step, stl, "3mf"}, facts: {bbox_mm, volume_mm3, ...}, printability: {ok, manifold, watertight, ...}}` — STEP is AUTHORITATIVE, STL/3MF are sidecars; failures return HTTP 200 with `ok:false, error{kind,message}`, never assume 200 means success. Author ONE reviewed `gen_step(**params)` generator per part family at build time (never have an LLM write fresh CAD source at request time); the deployed app's runtime only ever calls forge:cad with the user's chosen params. ⚠️ **Every dimension the brief states MUST be asserted against the returned `facts` (bbox/volume) before the part is shown to a user** — a generator can export a valid watertight STEP that is still dimensionally WRONG, silently. Surface `printability.ok:false` as a failure state, never hand over an unprintable file. **Use for**: brackets, enclosures, mounts, jigs, clips, gaskets, hole patterns, parametric configurators — anything mating with real hardware. **Do NOT use for**: organic/decorative/sculptural shapes with no functional dimensions (`forge:3d` instead) or browser-side interactive scenery with no print intent (img2threejs procedural discipline instead). ⚠️ **MANDATORY SELF-TEST before finishing**: POST every generator you author with representative params and require `ok == true`, `printability.ok == true`, AND `facts.solids == 1` — fix and re-run until it passes; ~2s, no excuse to skip. **Do NOT invent build123d API names** — verified surface: sketch objects `Rectangle(w,h)`, `RectangleRounded(w,h,radius)` (NOT `RoundedRectangle` — doesn't exist), `Circle(r)`, `Ellipse`, `RegularPolygon`, `SlotOverall`, or `Polyline([...], close=True)` + `make_face()`; `extrude(amount=x)` / `extrude(amount=x, both=True)` (NO `symmetric=` kwarg); `Hole(radius)`, `Locations([...])`, `Mode.SUBTRACT`, `fillet`/`chamfer` applied LAST; `part.is_manifold` and `part.is_valid` are PROPERTIES, not methods. **One solid, not several** — two `extrude()` calls whose bodies never touch produce disjoint solids that slice as loose pieces; prefer ONE profile extruded once (e.g. an L-shaped `Polyline` for a bracket, not a base slab plus a wall slab), always assert `facts.solids == 1`. ⚠️ **EXACT response shape (two field types already guessed wrong in production)**: `artifacts[fmt]` is an OBJECT `{url, bytes}`, `url` a public CORS-open link — fetch server-side or hand to the browser; NOT base64, never `Buffer.from()` it. `facts.bbox_mm` is an ARRAY `[x, y, z]`, NOT `{x, y, z}`. Real response: `{ok: true, artifacts: {stl: {url: 'https://infer.ohmyunicorn.com/v1/cad/artifacts/<uuid>.stl', bytes: 127884}, step: {...}, '3mf': {...}}, facts: {bbox_mm: [80.0, 50.0, 3.0], volume_mm3: 11599.57, center_mm: [0,0,1.5], solids: 1, faces: 12, edges: 24}, printability: {ok: true, manifold: true, watertight: true, winding_consistent: true, euler_number: -6, volume_agreement_pct: 0.0, fits_bed: true, warnings: [], errors: []}, timing_ms: 1966}`. `printability.ok` is AUTHORITATIVE — gate downloads on it, not a subset of flags. ⭐ **A reviewed feature library ships with the service — `import cad_features as F`** (copied into the sandbox; do NOT re-derive): `F.clearance_hole(size, depth)`, `F.counterbore(size, depth)`, `F.countersink(size, depth)`, `F.hex_nut_blank(size)` (captive-nut POCKET to subtract — the robust way to get a thread into a printed part), `F.hex_prism(across_flats, height)`, `F.spur_gear(module_mm, teeth, thickness, bore=)`, `F.knurl(diameter, height, teeth=)`, plus `F.spec('M4')` / `F.clearance_hole_dia('M4','normal')` / `F.tap_drill_dia('M4')` for ISO 273/4762/4032/68-1 data. `size` is a string `'M2'`..`'M12'`. **Every helper returns a STANDALONE solid — subtract with `add(F.counterbore('M4', depth=6), mode=Mode.SUBTRACT)` inside `with Locations((x, y, z)):`; a helper never modifies the part in place.** ⚠️ Use a clearance hole not a nominal one — FDM holes print 0.1-0.3mm undersize, so M3 clearance is Ø3.4mm not Ø3.0mm. ⚠️ **NO thread primitives, do NOT hand-roll one** — a helical sweep in this build123d version cuts turns unreliably (±4% material, length-dependent); use a clearance hole plus `F.hex_nut_blank` and real hardware. ⭐ **Declare `PARAMS` so the configurator form is GENERATED, not hand-built**: a module-level dict beside `gen_step`, e.g. `PARAMS = {'width': {'min': 20, 'max': 200, 'step': 1, 'unit': 'mm', 'group': 'Dimensions', 'label': 'Overall width'}, 'style': {'options': ['square','round']}}`. `POST .../v1/cad/manifest {source}` returns `{ok, params: [{name, type, default, widget, label, group, min, max, step, unit, options}], groups, warnings}` — render the form from it (`widget` ∈ `slider|number|select|switch|text`); free, call on page load. **Bounds you declare are ENFORCED**: `/v1/cad/generate` returns `ok:false, error.kind='bad_params'` for out-of-range/bad-enum/unknown params BEFORE running the generator. Read `warnings` — a `PARAMS` key matching no `gen_step` parameter, or a default outside its own range, is reported there. ⭐ **Add `'views'` to `exports` and LOOK before calling a generator done.** `exports: ['step','stl','views']` returns `artifacts.views.url`, a 7-view PNG sheet (iso/front/back/left/right/top/bottom), plus `views: {ok, triangles, bbox_mm}`. bbox/volume/manifold/watertight ALL pass on a part missing a briefed feature entirely (measured: an L-bracket missing 2 of 4 required holes passed every numeric check, identical bbox). **A dimensional assertion cannot see a wrong shape; the sheet can.** ⭐ **MULTI-PART ASSEMBLIES use a DIFFERENT endpoint and gate — `POST .../v1/cad/assemble`.** A printable part must be ONE watertight solid; an assembly must not be, so ⚠️**`facts.solids == 1` does NOT apply to an assembly**. Define `gen_assembly(**params)` returning a LIST of part dicts instead of `gen_step`: `[{'name': 'base plate', 'part': <build123d Shape, placed in assembly coords>, 'color': '#6f8fbf', 'explode': (0,0,-1), 'envelope': True}, {'name': 'pinion', 'part': ..., 'contacts': ['gear']}]`. `name` required+unique (BOM/clash-report/exploded-view id); `color`/`explode` drive the exploded view; **exactly one part may declare `envelope: True`**, every other part checked inside its footprint; `contacts` declares an EXPECTED touch (meshing gear teeth genuinely share a little volume) — undeclared overlap is a clash. Request `exports: ['step','views','exploded']`. Response: `{ok, parts: [{name, volume_mm3, bbox_mm, solids, manifold}], assembly: {ok, part_count, interference: {ok, calibration, pairs_tested, clashes: [{a,b,shared_mm3}]}, containment: {ok, violations: [{name, overhangs_mm}]}, unprintable_parts}, artifacts: {step, views}, part_artifacts: [{name, url}]}`. **`assembly.ok` is authoritative** — gate downloads on it; `part_artifacts` gives one STL per part. ⚠️**If `assembly.interference.calibration.ok` is false the clash report is INCONCLUSIVE, not clean** — say so, don't show a green tick. ⭐ **What the assembly gate does NOT check, assert yourself: it proves parts don't interpenetrate and sit inside the box — it does NOT prove the mechanism works.** Anything the brief implies about how parts MATE is your assertion against measured geometry, like stated dimensions on a single part. Measured: a meshing gear pair needs centre distance = `module*(z1+z2)/2` AND the two gears PHASED by half an angular pitch (`180/z` degrees) so a tooth sits in a gap — both authored at phase 0 interlock tooth-on-tooth (123 mm³ shared volume) in a render that looks normal. Assert centre distances, running clearances, phase offsets; then LOOK at the exploded view before calling it done.
@@ -7225,6 +7226,14 @@
                 )
         except Exception as _av_exc:  # noqa: BLE001
             log.warning("[fast-build] asset vendoring skipped (non-fatal): %s", _av_exc)
+        try:  # 2026-10-18: Forge 3D library models the agent chose (library-manifest) are copied in. Fail-open.
+            from forge_engine.pipeline.library_vendor import vendor_library_assets as _vla
+            _lv = await _vla(sandbox)
+            if _lv.get("manifest"):
+                log.info("[fast-build] library-manifest vendored for %s: vendored=%d skipped=%d dropped=%d failed=%d",
+                         project_slug, _lv.get("vendored", 0), _lv.get("skipped", 0), _lv.get("dropped", 0), _lv.get("failed", 0))
+        except Exception as _lv_exc:  # noqa: BLE001
+            log.warning("[fast-build] library vendoring skipped (non-fatal): %s", _lv_exc)
         try:
             from forge_engine.pipeline.image_gen import generate_manifest_images
             _img = await generate_manifest_images(sandbox, stage_run_id=stage_run_id)
