Sketch Incremental Placement (LLM-driven scene generation)
Sketch-stage generates or densifies OpenUSD scenes from bounds / scale / pack / intent inputs. The engine is the LLM: instead of computing all positions up front from one fixed ruleset, the LLM queries the stage and places objects through a tool surface, with a collision gate that rejects overlap and a live 2D+3D viz the user watches.
Its USD reading and writing currently go through OpenUSD (pxr / usd-core) — it doesn't use nanousd. Its output — large prim-count stages — is used as test/benchmark input for nanousd.
Inputs
| Field | Default | Notes |
|---|---|---|
bounds |
— | Empty-canvas mode. WxD or WxDxH in meters (e.g. 10000x10000). Synthesizes an anchor with no placements — just a footprint shell of the requested size. The LLM places everything guided by intent + the pack's archetype semantics. Requires --pack. This is the recommended starting point for any themed generation. |
scale |
medium |
small (+30), medium (+120), large (+600), huge (+2400) — count of NEW placements the LLM aims for on top of the anchor |
intent |
— | Free-form NL goal: e.g. "<your theme description in your own words>" — drives placement decisions |
out |
~/.cache/sketch-stage/demo |
Output dir; events.jsonl, snapshots, and realized USD live here. Pass --out to override. |
mode |
auto |
Densification mode after all template placements are bound: auto / fill / tile / passthrough. See "Densification modes" below. |
absorb-source |
— | Optional. Local path or http(s):// URL to a USD. The skill runs absorb_pack.py on it first and uses the result as the anchor. The source must be SimReady-tagged — i.e. its ref/payload-bearing prims must carry one of: customData["simReady"], UsdSemantics labels, or SimReady Kind markers (component / assembly / group / subcomponent). The absorber refuses any source where < 50% of ref/payload prims carry such metadata; without SimReady info the LLM has no reliable way to bind absorbed placements to a target pack downstream. The absorbed sketch captures **layout + placement semantics only** — archetype labels come from the source itself (asset filename stem, falling back to prim name), slot dimensions from the prim's measured world bbox, sourcePath preserved for hierarchy analysis. Structural prims (footprint > 30 m², typically walls/floors/ceilings/facade panels) are filtered out and used only to size the shell. No original asset URL is carried forward — binding to actual pack assets is a separate LLM-driven step. |
absorb-pack |
— | Optional asset pack tag recorded in the absorbed sketch's metadata so realize knows which pack to consult for binding. Does NOT influence what the absorber captures. |
resume |
— | Optional. Path to a prior run's _snapshot.sketch.json to pick up where the previous session left off. See "Resume from snapshot". |
Densification modes
After every template placement is bound, the LLM has three ways to grow the scene up to addTarget / target prim count:
fill— fill the empty space inside the existing template footprint with collision-checked, semantics-aware free placements. Footprint stays the same; density increases. Use when the user wants the same scene "fuller".tile— replicate the entire template (anchor placements + bound assets) on a non-overlapping grid. Footprint grows; layout repeats. Use for prim-count-target benchmarks where you want N copies of the same room.passthrough— bypass per-placement realize entirely; tile the pack'srootStageUSD as a single unit. Use only when the pack declaresrootStagein itspack.json.auto(default) — pickfillforsmall/mediumscales; picktile(orpassthroughif the pack supports it) forlarge/huge. Preferfillwhen the user's intent is qualitative ("denser", "fuller"),tilewhen quantitative ("hit 100k prims").
Trigger phrases for fill: "fill the empty space", "densify without changing the footprint", "more objects in the same room", "don't tile, just fill".
Kit is not part of generation. Use Kit only after realization to open, inspect, collect, or validate the generated USD.
Three-tier workflow: LLM authors STRATEGY, scripts EXECUTE, LLM oversees
This is the most important discipline in the skill.
Pure LLM-controlled placement does not scale: 10,000 placements × ~200 tokens × $0.005/k ≈ $10 per scene and ~1.4 hours of inference time. Pure deterministic placement doesn't capture intent: a grid walker can't know that "archetype A in aisles, not next to archetype B" means anything until the rule is encoded in the pack's semantics.
The boundary that works is a three-tier hybrid:
| Tier | Who | What | Frequency |
|---|---|---|---|
| 1. Strategy | LLM | Parse user prompt → decompose canvas into regions → per-region archetype mix → write preferredNear/avoidNear/placementBias into pack.semantics → pick the right script for each pass |
once per task |
| 2. Execution | Scripts | Per-cell coord math, weighted random archetype choice, semantic gate (avoidNear reject, preferredNear cluster), placementBias cell weighting, sibling collision, on-surface localXY sampling |
thousands of times per task |
| 3. Oversight | LLM | Look at realized output (or query_stage_graph summary), decide if another pass / different region / specific one-off placement is needed |
once after each pass |
What the LLM authors during a task:
- Plan JSON —
random_fill --plan '{...}',densify_zones --zones '{...}'. Strategic input. - Pack-semantic edits — small
pack.jsonpatches (preferredNear, avoidNear, placementBias) usingEditon the existing file. Once, not per placement. - Inline
place_manyJSON via curl — for anomalies that don't fit a tool (one-off placement at a specific spot, manual correction). Use when fewer than ~30 placements need bespoke logic.
What the LLM must not do:
- ❌ Write Python files to
/tmp/<run>/something.py. If you find yourself authoring a script to compute placements, stop. Chain existing scripts or POST inlineplace_manyfor the bespoke part. - ❌ Reinvent grid math —
densify_zones.pywalks deterministic row-major grids over any region. - ❌ Reinvent weighted scatter —
random_fill.pyshuffles cells, picks archetype by weight, applies the semantic gate. - ❌ Reinvent on-surface placement —
surface_fill.pyiterates the runtime surface registry and postsplace_onper slot.
Pick the right script per pass
| Pattern you need | Script | Determinism |
|---|---|---|
| Regular grids (rack rows, parking spaces, server pods) | densify_zones.py |
fully deterministic — row-major, no RNG |
| Irregular scatter, density-fill, "fill the empty space" | random_fill.py --semantic-gate |
reproducible given --seed |
| On-surface stacking (boxes on shelves, cups on counter, monitors on desks) | surface_fill.py |
reproducible given --seed |
| One-off placements, specific landmarks at known coordinates | inline place_many JSON via curl |
n/a |
| Reusable pattern that doesn't fit any of the above AND likely to recur | NEW script committed to skills/sketch-stage/scripts/ — never /tmp |
up to author |
When in doubt: chain existing scripts. If a chain of 4-5 CLI invocations + one inline curl does the job, that's the right shape. Compose from primitives — the skill ships no domain-specific recipes; the workflow is the recipe.
session_http API schema lives in references/session_api.md. Read it BEFORE writing any inline Python/curl against the live session — the recurring failure mode is calling .get(...) on an endpoint that returns a list (≈ half of them do) or len(...) on a count that's an integer.
Per-run flow
Step 0 (one-time): provision the venv
The skill installs usd-core, omniverse-asset-validator, mcp, and numpy into a per-skill venv at ~/.cache/sketch-stage/venv. scripts/run.sh calls scripts/setup_venv.sh automatically on every invocation; the setup script is idempotent.
bash $SKILL_DIR/scripts/setup_venv.sh # explicit invocation if you want to pre-warm it
Override the location by exporting SKETCH_INCREMENTAL_VENV before the call.
Step 1: launch session and viz
OUT="$PWD/sketch_runs/my_run"
bash $SKILL_DIR/scripts/run.sh \
--bounds 200x150 --pack /path/to/asset_pack \
--scale medium --intent "<your theme>" --out "$OUT"
The script prints a JSON config with: chosen anchor + pack, target additional placement count, session HTTP URL (http://127.0.0.1:<session_port>/), live viz URL (http://localhost:<viz_port>/), and the available tool names. Open the viz URL in a browser; the page renders 2D top-down on the left and 3D Three.js on the right, both with the template shell + zones as a backdrop.
Multi-tenant ports. Two concurrent run.sh invocations get isolated servers automatically — the script tries the defaults (session=8766, viz=8765) first and bumps to the next free port (up to +32) if either is taken. The chosen ports come back in the JSON config's session.url / viz.url fields; subsequent tool calls must use those URLs, not the hard-coded 8766. Pass --session-port N / --viz-port N to demand a specific port (will fail loudly if taken). The legacy "kill anything on this port before binding" behaviour is opt-in via --force-kill — by default, run.sh never disturbs another user's session.
Per-session live-event logs land at $TMPDIR/sketch_live/<session-port>/events.jsonl so concurrent viz processes don't clobber each other. Override with the SKETCH_LIVE_EVENTS env var.
The LLM MUST tell the user the viz URL immediately after launching the session, in plain text on its own line, like this:
Live viz: http://localhost:8765/ (open this in a browser to watch placements arrive)
Repeat this line whenever a new session starts.
Step 2: orient
Always do these calls first:
GET /status— anchor's placement count + footprintPOST /tool/template_view— site shells + zones with their world origins and bounds. Required reading before placing in empty-canvas mode; tells you the valid XY region in which placements must lie.POST /tool/query_zones— zones to target (merges spatial zones + LLM-labelled regions fromtemplateZones[]ifextract_regions.pyhas run)POST /tool/query_pack_archetypes— what archetypes the pack offers; each entry includes asemanticsblockPOST /tool/query_archetype_semantics(optional, no body) — full semantics map for every archetypePOST /tool/query_surfaces— surfaces available forplace_on(counters, table tops, rack tops, shelves). Filter byregionId,minFreeAreaM2, orlabel.
Coordinate convention. Every shell / zone defines its valid XY region as [originWorldM[0], originWorldM[0] + boundsM.widthM] × [originWorldM[1], originWorldM[1] + boundsM.depthM]. Empty-canvas mode ships with a single site shell at originWorldM=[0,0,0] extending to (widthM, depthM) — so valid coords are [0, W] × [0, D], NOT [-W/2, +W/2]. The collision gate rejects placements whose posM[0:2] is outside every declared shell and zone with reason="out_of_bounds"; the cheap fix is to read template_view first.
Read every archetype's semantics.typicalContext and semantics.avoidNear before you commit any placements. Ignoring them produces nonsense layouts (objects placed where their avoidNear neighbors already sit, perimeter objects placed mid-room, etc.).
Step 3: plan an archetype-mix distribution (intent expansion)
Translate the user's intent and addTarget into per-archetype goals. For larger scenes or short-but-evocative intent ("dense data center", "automotive parts depot"), borrow MesaTask's "object list completion" pattern (arXiv 2509.22281 §4.2): instead of asking the user to enumerate every archetype, expand the theme into the FULL set of co-occurring objects a realistic scene would contain — data center → server racks AND CDUs AND CRAHs AND PDUs AND fire extinguishers AND walkways — then weight per-region according to how a real scene of that theme looks.
Division of labor — scripts own everything deterministic so the LLM only spends tokens on theme reasoning:
| Step | Who | Why |
|---|---|---|
| Inventory pack archetypes, filter invalid bboxes, compute per-region attempt budgets | pack_summary.py |
Deterministic. On a large pack (200+ archetypes), having the LLM read raw pack.json costs hundreds of lines of context for what's effectively a jq query. |
| Define regions, pick archetypes for the theme, map archetypes → regions, set per-region weights, write per-region notes, flag archetypes that should be in the pack but aren't | LLM | Non-deterministic. Requires theme priors no script can reproduce. Missing-archetype flagging is the most knowledge-intensive — it reasons about what's outside the pack. |
| Cross-check the LLM's plan against the pack (hallucinated archetypes, sentinel bboxes, omitted archetypes, malformed weights) | validate_archetype_plan.py |
Deterministic. The LLM can't reliably re-check its own JSON without burning the same token cost as inventory. |
Slice per-region into random_fill.py's --plan form |
jq one-liner |
Trivial transform. |
Workflow:
Summarize the pack and compute the attempt budget:
"$($SKILL_DIR/scripts/setup_venv.sh)/bin/python" \ "$SKILL_DIR/scripts/pack_summary.py" \ --pack /path/to/asset_pack \ --regions '{"<region_a>": {"weight": 0.80}, "<region_b>": {"weight": 0.15}, "<region_c>": {"weight": 0.05}}' \ --target-placements 10000Output (compact, one record per line):
3 archetype(s), 8 valid asset(s), upAxis=Z, mpu=1.0 <arch_a> : 3 assets · 1.3×1.2×3.2 m – 1.3×1.2×3.2 m <arch_b> : 3 assets · 1.6×3.7×3.4 m – 0.92×2.5×2.1 m <arch_c> : 2 assets · 1.6×0.66×2.4 m – 1.4×0.6×2.3 m per-region attempt budget (target=10000, ×1.3 cushion): <region_a> (weight 0.8): 10400 attempts <region_b> (weight 0.15): 1950 attempts <region_c> (weight 0.05): 650 attemptsLLM writes the plan to
$OUT/expanded_plan.json. Schema (matchesrandom_fill.py's expected--planafter slicing each region):{ "intent": "<one-sentence theme description>", "targetPlacements": 10000, "perRegion": { "<region_a>": { "attempts": 10400, "archetypes": {"<arch_a>": 12, "<arch_b>": 3, "<arch_c>": 1}, "note": "<region_a> dominates; <arch_b> interspersed every few rows" }, "<region_b>": { "attempts": 1950, "archetypes": {"<arch_b>": 5, "<arch_c>": 2, "<arch_a>": 1}, "note": "perimeter biased toward <arch_b>" } } }Use the per-region
attemptsvalues frompack_summary.pydirectly (cushion already applied).noteis free-form LLM commentary.Include an
_llmNotefield listing archetypes that would belong in the theme but are absent from the supplied pack. This is the irreducible LLM step — neitherpack_summary.pynorvalidate_archetype_plan.pycan produce it, since both see only what's in the pack.Validate the plan against the pack:
"$($SKILL_DIR/scripts/setup_venv.sh)/bin/python" \ "$SKILL_DIR/scripts/validate_archetype_plan.py" \ --pack /path/to/asset_pack \ --plan $OUT/expanded_plan.jsonExit codes: 0 ok, 1 hard errors (hallucinated archetypes, sentinel bboxes, bad weights — stop), 2 warnings only (omissions — caller decides).
Slice each region into
random_fill.py's--planform and execute (see Step 4b).
Step 4: place
There are two modes, and choosing between them is the most common boundary error:
| Use case | Mode | Section |
|---|---|---|
| >50 placements of the same archetype with shared constraints | bulk-fill scripts (skip per-placement query_nearby) |
Step 4b |
| <30 bespoke placements (one-offs, specific landmarks, corrections) | per-placement with place + query_nearby first |
Step 4a |
| On-surface placements (boxes on shelves, props on tables) | surface_fill (a bulk variant of place_on) |
Step 4b |
The semantic gate (avoidNear/preferredNear/placementBias) runs inside both bulk scripts and the place tool — it's a property of the engine, not of the workflow. Per-placement query_nearby is only needed when the LLM is making contextual decisions (e.g. "what's near here that I should react to?"), NOT to enforce semantic constraints.
Step 4a: per-placement (small batches, bespoke logic)
For each placement target:
Query nearby context FIRST.
POST /tool/query_nearby {posM, radiusM, limit}. Each returned placement includes asemanticsblock. Read neighbors' semantics, not just their archetype names.Apply semantic rules before committing:
- The current archetype's
preferredNearshould overlap with at least one neighbor's archetype, OR the spot should be near an open zone where that archetype typically lives. - The current archetype's
avoidNearshould NOT include any close neighbor's archetype within ~2 m. If it does, pick a different point. - General patterns: archetype whose
placementBias = "edge"→ near footprint walls/columns, never mid-zone. Archetype whoseplacementBias = "center"and whoseavoidNearincludes its perimeter counterparts → centre of the room.
- The current archetype's
Then
POST /tool/placewith{archetype, posM, slotM, yawDeg, id}. The collision gate (3D OBB-SAT) rejects overlaps — structural safety net; semantics are the reasonableness check on top.On rejection, pick a different point AND re-check semantics for the new context (don't blindly retry far away from a thoughtful spot).
Stop retrying after a few attempts per goal; report unfilled goals back honestly.
For asset-specific picks (e.g. "fill rack slots with the 3m rack model, not the large variant"), the LLM should pick assets explicitly:
POST /tool/query_pack_assets— returns all candidates per archetype with absolute + relative paths.- For each placement, decide: which specific
assetPathto use, and whether toscaleMit to fit a non-matching slot. - Pass to
place:{archetype, posM, slotM, yawDeg, id, assetPath, scaleM}.
Step 4b: bulk fill (scripts)
Three bulk-placement scripts in the same family, picked by placement surface:
densify_zones.py — regular grids on the floor. Reads zones from the live session, infers each archetype's slot dims from existing placements (median slotM), walks a deterministic row-major grid over each region with cell pitch = max(slotW, slotD), and POSTs the batch. Use when the user wants rows/grids/lattices.
"$($SKILL_DIR/scripts/setup_venv.sh)/bin/python" \
"$SKILL_DIR/scripts/densify_zones.py" \
--session http://127.0.0.1:8766 \
--zones '{"<region>":{"<archetype>":<count>, ...}, ...}'
Slot lookup falls back to --slot <arch>=w,d,h when an archetype has no existing placements. No zones in the template (empty-canvas mode, custom anchor without zone prims): declare ad-hoc rectangles with --region NAME=ox,oy,w,d (repeatable). Names in --zones resolve to a --region first, then to a session zone.
Patterns:
# Pack zones tighter — existing zones, slot dims inferred from placements
densify_zones.py --session http://127.0.0.1:8766 \
--zones '{"<zone_a>":{"<arch_x>":<count>,"<arch_y>":<count>}, \
"<zone_b>":{"<arch_x>":<count>,"<arch_y>":<count>}}'
# Empty-canvas — quadrant A for arch_a, quadrant B for arch_b, central spine of arch_c
densify_zones.py --session http://127.0.0.1:8766 \
--region region_a=0,0,1000,1000 \
--region region_b=1000,1000,1000,1000 \
--region spine=0,950,2000,100 \
--zones '{"region_a":{"<arch_a>":600}, \
"region_b":{"<arch_b>":1200}, \
"spine":{"<arch_c>":150}}'
# Mount <arch_w> along the south wall of region <r> — thin strip, slot specified
densify_zones.py --session http://127.0.0.1:8766 \
--slot <arch_w>=0.2,0.2,0.6 \
--region south_wall=0,0,144,1.5 \
--zones '{"south_wall":{"<arch_w>":28}}'
Same session state + same args → identical output (no RNG).
random_fill.py — irregular scatter, density-fill. Takes one or more regions (declared inline with --region, picked up from query_zones, or auto-derived from the shell with --auto-shell), a per-region archetype weight mix, and an attempt budget; shuffles a coarse cell grid (seeded RNG) and POSTs a single place_many per region.
The semantic gate (default ON when --pack is given) consults pack.semantics per candidate cell:
avoidNear: rejects the cell if any neighbor within--neighbor-radius-m(default 2 m) matches the candidate's avoidNear list. Hard reject.preferredNear: soft — only enforced once at least one neighbor exists. Lets the first placement bootstrap; subsequent placements have to cluster with at least one preferredNear archetype.placementBias:"edge"/"center"/"corner"/null. Modulates per-cell archetype-choice weights by the cell's edge-distance score. Cap is0.05so a strongly-biased archetype can still appear in a mismatched cell at low rate.
Pass --pack <path> to enable the gate. Pass --no-semantic-gate to revert to pure weighted random. The per-region report grows semSkipAvoid and semSkipPreferredMiss counts so you can see the gate's filter rate.
"$($SKILL_DIR/scripts/setup_venv.sh)/bin/python" \
"$SKILL_DIR/scripts/random_fill.py" \
--session http://127.0.0.1:$SESSION_PORT \
--pack /path/to/pack \
--auto-shell \
--plan '{"shell": {"attempts": 8000, "archetypes": {"<arch_a>": 5, "<arch_b>": 2, "<arch_c>": 1, "<arch_d>": 1}}}' \
--seed 42
Region-selection strategies for random_fill (config inside the plan):
shell(default, required-at-least-once) — sweep every cell across the whole site shell, including the strips between declared zones. Without it, inter-zone gaps stay empty and the realized stage looks like isolated lattices floating in a void. For a cell at(x, y): if the cell falls inside a zone, use that zone'sallowedArchetypes; if it falls in an inter-zone gap, use a generic mix of pack archetypes reasonable for an open floor.zone—random.choiceone zone fromquery_zones(), densify only inside it. Use only when the user names the zone: "fill the dock zone tighter."area— pick a random sub-rectangle inside the footprint. Use when the user names a region by location: "add more activity in the NW corner."mixed— run several passes: 1×shellfirst for global coverage, then 1–2×zonepasses biased toward zones still below target density, then optionally 1×areafor visual variety. The first pass must beshell.
Whichever strategy you pick, shuffle the resulting cell list before greedy-placing — never iterate in index order.
surface_fill.py — on-surface placements (boxes on shelves, cups on counters). Walks the runtime surface registry, picks an archetype per slot, posts place_on. Requires the owning archetype's semantics.surfaces to be declared (see "Enrich pack with semantics") plus an explicit slot for the thing being placed.
"$($SKILL_DIR/scripts/setup_venv.sh)/bin/python" \
"$SKILL_DIR/scripts/surface_fill.py" \
--session http://127.0.0.1:$SESSION_PORT \
--surface-label shelf \
--archetypes '{"Boxes": 6, "Containers": 2}' \
--slot 'Boxes=0.5,0.5,0.4' \
--slot 'Containers=0.6,0.6,0.7' \
--per-surface-max 5 \
--layout grid \
--seed 42
--layout grid (default) lays a deterministic row-major lattice per surface sized to the slot. --layout random is the legacy mode — useful for organic scatter but saturates at ~1–2 placements per surface because subsequent random samples keep colliding with the first one.
surface_fill automatically passes the surface's topWorldZ through to place_on so multi-shelf owners (e.g. a rack with surfaces at z=1, 2, 3) get each shelf addressed individually.
--slot ARCH=w,d,h is mandatory for every archetype — the script has to know each on-surface placement's size to compute per-surface capacity AND to validate the placement fits within the surface's footprint.
Common patterns (trigger phrases → invocation):
| User phrase | Invocation |
|---|---|
| "items on the shelves" | --surface-label shelf --archetypes '{"<arch>": 1}' --per-surface-max 5 |
| "props on the table" | --surface-label top --archetypes '{"<arch_a>": 3, "<arch_b>": 2}' --layout random |
| "only fill the storage zone, not the office" | add --region storage_aisles |
Floor-first, surfaces-second. Run floor placements before on-surface ones — anchors and their surfaces become available in the registry as they're inserted, so surface_fill can decorate freshly placed anchors. (The runtime surface registry updates incrementally on every place / place_many.)
Step 5: realize and report
POST /tool/realize {}
Returns {ok, rootUsd, manifest, snapshot, summary, templateBreakdown}. manifest is the path to manifest.json on disk (a string), NOT the parsed dict — load it with json.load(open(d["manifest"])) if you need its inner fields.
Use scripts/report.py to summarize the run instead of writing inline-python inspection blocks per check:
python3 $SKILL_DIR/scripts/report.py <out_dir>
It prints paths, placement counts, composition strategy, per-archetype real-asset breakdown, unfilled warnings, and live-session status — same fields as the table below:
| Field | Source | Meaning |
|---|---|---|
anchorPlacementCount |
templateBreakdown.anchorPlacementCount |
placements the absorbed template gave you (the "1 tile" baseline) |
addedPlacementCount |
templateBreakdown.addedPlacementCount |
placements you added on top via tools |
perTilePlacementCount |
same as anchor count when tiling | placements per tile copy |
tilesActuallyPlaced |
addedPlacementCount / perTilePlacementCount + 1 |
total tile copies materialized |
totalPlacementCount |
templateBreakdown.totalPlacementCount |
session sum |
byArchetype filled / picks |
summary.byArchetype |
per-archetype counts |
composedPrimCount_usdcore |
manifest["composedPrimCount_usdcore"] |
usd-core composed prim count (undercount when references don't load locally) |
unfilled[] |
summary.unfilled |
placements that didn't fit the pack and weren't synthesized |
rootUsd |
rootUsd |
path to root.usd to open in Kit |
compositionStrategy |
manifest["compositionStrategy"] |
composition arc type, hierarchy, instancing |
layers |
manifest["layers"] |
root layer, bulk content layer, wrapper layers |
If you tiled to hit a target prim count, also report: tile count attempted, per-tile placements, placed/rejected per tile, total composed prim count vs target.
Diagnosing "things look overlapped" after realize. Run python3 $SKILL_DIR/scripts/check_overlaps.py [--session URL] [--pack /path] against the live session. It audits three failure modes the user can't tell apart by eye: (1) actual OBB-overlap (gate bug or gate bypass), (2) rendered-asset bbox bigger than its declared slot, (3) pivot drift between asset origin and bbox-center-floor. Prints the first 20 offending pairs + per-archetype slot-vs-asset-bbox table.
Step 6: iterate (optional)
The user can prompt for more changes — "add more <arch> along the central aisle", "remove all the <arch>s", "replace the south-row items with the larger variant" — and the LLM repeats Steps 2–5.
Step 7: teardown (REQUIRED at end of task)
After the user is done — and especially after realize if no follow-up is expected — the LLM MUST shut the session server down. Leaving the server running ties up its port forever and (over a long Claude Code session) leaks file descriptors.
# Path A (HTTP, what /sketch-stage uses internally):
curl -s -X POST http://127.0.0.1:$SESSION_PORT/tool/shutdown -d '{}'
# Also kill the viz process (its PID came back from run.sh's launch JSON
# under viz.pid):
kill $VIZ_PID 2>/dev/null || true
# Path B (native MCP):
mcp__sketch-placement__shutdown()
The shutdown tool exits the server in a background thread so the response is delivered first. Multiple calls are idempotent. If you forget, the next run.sh invocation will auto-bump to the next free port, so the cost is "an orphan process and a leaked port" rather than a hard failure.
Special modes
Empty-canvas / theme-driven generation
When there's no anchor template to start from (the recommended starting point for any non-absorbed scene), the skill synthesizes a shell-only anchor of the requested size via --bounds:
OUT="$PWD/sketch_runs/theme_run"
bash $SKILL_DIR/scripts/run.sh \
--bounds 10000x10000 \
--pack <asset-pack-root> \
--intent "<your theme description — describe regions / sub-zones / archetype mix in plain language>" \
--mode fill \
--out "$OUT"
The LLM is then responsible for the entire layout. The workflow:
Fetch the canvas bounds first. Call
POST /tool/template_viewand read the single shell entry:originXY = shell.originWorldM[0:2]andextentXY = (originXY[0] + shell.boundsM.widthM, originXY[1] + shell.boundsM.depthM). All subsequent placements must haveposM[0:2]inside[originXY, extentXY]. Empty-canvas mode always usesoriginWorldM = [0, 0, 0]— the shell extends from(0, 0)to(W, D), NOT centered at origin. The place-gate hard-rejects out-of-bounds placements withreason="out_of_bounds".query_zones()will return empty. The LLM partitions the canvas into theme-zones from theintentusing(originXY, extentXY). It can author logical zones inline by remembering footprint sub-rectangles in working memory — it does NOT need to author USD zone prims; the partitioning is conceptual, used to drive per-zone archetype mixes.query_pack_archetypes()+query_pack_assets()+query_archetype_semantics()for the pack's archetypes + semantics.Translate the intent into a per-zone archetype distribution (each zone gets its own archetype mix and weights). Use holistic, semantics-aware reasoning — see "Anchor-binding strategy" below for the cross-pack mapping pattern.
For each zone:
- Pick anchor lines / aisles / clusters that match the theme.
- Generate placement coordinates per the archetype's
preferredNearandavoidNear. Constrain every coord to[originXY[i] + margin, extentXY[i] − margin](margin ≥ slot/2 keeps bboxes off the wall). place_manywith explicitassetPath+scaleM+yawDeg. Collision gate prevents overlaps.
Realize. If the canvas still feels sparse vs the user's
intent, runmode=fill(Step 4b) to densify.
Don't try to literally fill 10km × 10km uniformly — at 1 placement per 4m² that's 25M placements, far past authoring capacity. Aim for visual density: clusters of placements in zones, big empty corridors between, sparse scatter elsewhere.
For very large canvases (≥1km), tile your own theme blocks: build one 200×200m block of a coherent layout, then place_many translated copies across the canvas with deliberate spacing.
Asset pick must match canvas scale + theme
Real asset libraries hold parts at true real-world size, ranging from 5 cm (a knife) to 5 m (a counter run). For empty-canvas work, "fits the role semantically" is necessary but not sufficient — the asset must also be visually appropriate at the target canvas scale:
- After
query_pack_archetypes+query_pack_assets, inspect each candidate'ssize. Reject candidates whose native size is too small to be visible at the canvas's typical camera distance, unless you're going to scale them up to display dimensions. - For canvases ≥ 100 m, prefer assemblies (whole engines, full frames) over tiny sub-components (injectors, bolts). The user's "automotive parts depot" doesn't mean "scatter 5 cm fuel injectors across 1 km" — it means "showroom of recognizable parts at depot-display scale".
- When multiple assets in the pack work for a role, prefer the larger one for big canvases and the smaller one for indoor/room-scale anchors.
- Decide visibility scale per-zone: a "showroom display" at depot scale wants a slot of ~5–10 m on a side; a "shelf prop" wants 0.3–0.5 m. Match
slotMto the intended display size, not the asset's raw size. - Bind with
bind_archetype(..., fitMode="fit-to-slot")so the engine derivesscaleM = slot / asset_world_bboxper placement. This decouples your asset choice from the placement's visible size.
Canvas-to-display ratio rule. A placement is visible from Kit's default camera framing when its largest dimension is roughly 1–3% of the canvas's largest dimension. Below ~0.5% it's too small to read; above ~10% it dominates and the layout collapses to a few items.
| Canvas | Comfortable display size | Example |
|---|---|---|
| 100 m | 1–3 m | indoor room — typical assets at native size |
| 1 km | 10–30 m | small depot — assemblies on display platforms |
| 2 km | 20–40 m | medium depot — same parts with bigger slots |
| 10 km | 100–300 m | rare; needs scaled-up clusters or proxy structures |
If the user picks a 10 km canvas with intent at "depot" granularity, push back and suggest 1–2 km — or the visible result is a few specks scattered across an otherwise empty plane. The empty-canvas mode is most useful at 200m–2km.
Resume from snapshot
Every realize() writes _snapshot.sketch.json next to the realized root.usd. The snapshot contains the full session state at realize time — anchor placements, every added placement, every bound assetPath + scaleM + yawDeg, the zones, and the compositionStrategy.
To pick up where a prior session left off:
bash $SKILL_DIR/scripts/run.sh \
--resume /path/to/prior_run/_snapshot.sketch.json \
--scale small --intent "<incremental change>"
The new session loads the snapshot, the LLM can call query_template_placements/query_stage_graph to see what's already there, then add more placements via place/place_many. The --pack from the snapshot is reused unless overridden.
Anchor-binding strategy: bind real assets vs tile-to-target
After the template anchor is loaded, the LLM must fill all template placements before adding any free placements. There are two binding strategies:
A. Pack has real assets (the normal case). Asset choice and scale are reasoned LLM decisions, not arithmetic.
Cross-pack holistic mapping is REQUIRED for any absorbed anchor when the source's archetype names don't match the pack's archetype names — which is the default case with the vocabulary-free absorber, since absorbed archetypes come from the source asset filenames (Kitchen_Cabinet, Living_Couch, ...) and pack archetypes come from the pack's own categories (Cabinets, Counters, ...).
The LLM is responsible for this binding. If you realize() an absorbed sketch against a pack without binding first, every placement comes back unfilled (no candidate matches the source archetype name) and the realized USD is empty / synthesized cubes. Always run the binding pass first.
Binding pass:
- Pull all anchor archetypes — call
query_template_placements()(no arg) and group byarchetypefield. Each unique source archetype needs one binding decision. - Pull all pack archetypes + their assets —
query_pack_archetypes()+query_pack_assets()(no arg). Each entry includessize(bbox) andsemantics. - For each source archetype, pick the best-fitting pack asset considering:
- Slot dimensions — the source archetype's typical slot (visible via
query_template_placements) versus each pack asset'ssize. Pick the asset whose native bbox is within roughly 3× of the slot on at least one axis. If nothing is close, pick the closest and letfitMode="fit-to-slot"scale it. - Semantic role — the source archetype name and
query_archetype_semantics(description, typicalContext, preferredNear, avoidNear) versus the pack archetype's semantics (description, affordances, anchors). A "Living_Couch" maps best to a pack archetype withaffordances=["seating","rest"]; a "Kitchen_Counter" to one withsurfacesdeclared. When no semantic match exists, fall back to size + visual character. - Holism — reason about the whole mapping table together, not per-archetype-in-isolation. If a single pack archetype (e.g.
Equipment) is the only sensible match for several distinct source archetypes (Refrigerator, Oven, Washer), you may want to bind them to different assets withinEquipmentrather than all to the same one.query_pack_assets("Equipment")returns every candidate so you can pick visually distinct ones for distinct roles.
- Slot dimensions — the source archetype's typical slot (visible via
- For each source archetype, call
bind_archetype(archetype="<source_archetype>", assetPath="<chosen pack asset path>", fitMode="fit-to-slot"). Withfit-to-slot, the engine measures the asset's world-bbox at bind time and computes per-placementscaleMso the rendered geometry matches the source's slot exactly — no manual scale math needed regardless of source/pack mpu/upAxis differences. - Verify with
query_template_placements(unfilledOnly=true)— should return empty. If anything's still unfilled, that archetype's binding failed (e.g. asset path was wrong); the response frombind_archetypelists which placements got updated and which didn't. realize()— every placement now binds to a real mesh.
The pack's archetypeAliases (in pack.json) is a fallback convenience, not a substitute — the LLM's holistic mapping overrides it whenever the LLM has a better semantic match.
Honest reporting when no good match exists. If the source has archetypes the pack genuinely doesn't cover (e.g. apartment USD with Bedding22 but the pack is industrial-only with no soft-furniture analog), the LLM should pick the least-bad fit AND flag it back to the user — "I bound Bedding archetypes to Plywood_Crate as the closest visual match; consider running against a residential pack for better fidelity." Don't silently leave placements unfilled; the user wants the layout populated, even if imperfectly.
Up-axis correctness for scaleM. If the pack has upAxis: Y, the engine rotates each asset +90° about X at realize time so its Y becomes world Z. When computing scaleM = slot / asset_bbox, compare slot dimensions to the asset's world bbox after rotation, not its raw bbox:
asset bbox (asset frame, Y-up): (asset_x, asset_y, asset_z)
asset bbox (world after rotation): (asset_x, asset_z, asset_y) ← Y and Z swap
scaleM[0] = slot_widthM / asset_x
scaleM[1] = slot_depthM / asset_z # not asset_y
scaleM[2] = slot_heightM / asset_y # not asset_z
The engine then permutes scaleM back to asset-local axes before applying the scale op, so scaleM is always world-axis factors from the caller's perspective.
Per-archetype binding flow:
1. query_template_placements() # all anchor positions
2. group placements by archetype # one decision per archetype
3. for each archetype encountered:
a. query_archetype_semantics(archetype)
→ read description, typicalContext, preferredNear, avoidNear
b. query_pack_assets(archetype)
→ candidates (path, relPath)
c. PICK A SPECIFIC assetPath:
- prefer the candidate whose bbox best matches the slotM
- prefer candidates whose name + semantics matches the slot's
…(truncated)