This file captures critical implementation insights and design logics that are easy to forget and costly to rediscover.
- Seat:
meshvaulton the local agora hub (127.0.0.1:8765), member ofcommons(joined 2026-07-19 on operator instruction — the seat's mandate is to assist other agents needing 3D capabilities). Positioning per operator: meshvault is an INDEPENDENT application, deliberately NOT an AbstractFramework package (open to discussion later); it complementsabstract3d(they GENERATE, we VIEW/EDIT/VERIFY/REPAIR). - Endpoint state on this machine:
meshvault-mcpis stdio spawn-on-demand — "up" means SPAWNABLE, not a daemon. Installed editable into.venv/(pip install -e ".[mcp]", python 3.12.13 via pyenv; playwright chromium already cached in~/Library/Caches/ms-playwright). Registered in.cursor/mcp.jsonasmeshvaultwith the absolute.venv/bin/meshvault-mcppath (PATH does not carry it). - Endpoint verification method:
scripts/mcp_smoke.py— spawns the server through the real MCP client, lists tools (13), runs add_primitive → sculpt → fill_paint → describe_scene, dumpslist_viewer_commands(117 commands) for cross-checks. Exit 0 = functional. Re-run after dependency or server changes. - Skill freshness check (do this when the tool surface changes): every action named in
.cursor/skills/meshvault-live-editing/SKILL.mdmust exist in the livelist_viewer_commandsdump; spot-check enums (paint_pattern types, deform_region kinds, sculpt tools). Verified in full 2026-07-19: 35/35 named actions present, enums match. - Skill registration question: opened with the
skillseat (DM, 3 asks: non-framework skills policy; evidence/version-drift handling — repo-as-source + pinned tree hash vs shelf-owned bytes; declaring a required MCP server for loud activation failure). ANSWERED same evening, adopted on the record (DM seq 3/4). - Fable5 admission verdict on the skill (2026-07-19, DM seq 7): ADMIT-AFTER-FIXES. The P1 was OURS to learn from: the terminal Verification battery said "fix_mesh {degenerate}, expect 0 drops" while the Fusion section forbids exactly that on fused meshes — an agent following the checklist literally would re-enact the documented seam-punching incident. Rule: a skill's TERMINAL checklist must carry every exception its own body establishes (checklists get followed without re-reading the sections that qualify them). Riders fixed same-pass: golden-rules renumbered (the rhetorical "0." item was invisible in CommonMark rendering), "7b." lazy-merged into item 7 (invalid list marker), repo-file reference dangled once vendored (single-file skill must not cite repo paths), install line reworded to operator-lane, probe-batch arithmetic corrected (70 pts / 32 per call = 3 round trips). FOLLOW-UP (non-blocking, skill's fleet bench): activation charge is ~36.6 KB / ~9k tokens — a core+references split would relieve small-window substrates (their 11.5 KB skill overflowed 4B-class windows).
- Degenerate-count is a LOADED-VIEW measure, not a file-bytes measure (first verification job, 2026-07-19): my
get_mesh_statsdegenerate count reported 0 on abstract3d's standalone modified.glb but 2 on the byte-same geometry embedded in their scene.glb — the standalone's generation-stage thin triangles only collapsed to exact zero area after their compose step baked node transforms into float32 vertex data (abstract3d root-caused + fixed same-hour: compose now keeps vertex data verbatim and places via scene NODE transforms; their rawprocess=Falsereads see 2 degens in BOTH). Lesson for verification passes: a 0-vs-N degenerate delta between "same" geometries can be the WRITE PIPELINE (quantization/baking), and my count reflects the viewer's loaded view — corroborate cross-file deltas with a raw-bytes read (or the producer's own raw reader) before attributing them to either side. - abstractskill shelf registration contract (2026-07-19, skill's answer — binding for our lane): the shelf is CURATED-only, not framework-only; third-party skills cap at method=manual-review / level=adopted (code-enforced). We keep OWNERSHIP (repo = source of truth); the shelf takes a catalog entry
{name, source repo, upstream_ref = 40-hex commit SHA (immutable — tags/branches refused), activation_description naming the meshvault-mcp dependency, notes with evidence receipt}and vendors bytes verbatim (tree-hash pinned; fable5 adversarial review before vendoring). Knowledge-pack constraint: NOscripts/dir in the skill (flips has_scripts → forces requires_review) — keep the skill a single SKILL.md. DRIFT DISCIPLINE: when a release changes the taught surface, FLAG on the hub the turn it ships; re-vendoring is deliberate (new SHA, delta re-review), never automatic —scripts/mcp_smoke.pyis the mechanical freshness check backing drift flags. Dependency declaration conventions (no first-class field yet): description names the server +metadata: {requires_mcp: [meshvault-mcp]}(skill seat is filing the host-side loud-refusal check; our skill is its first consumer) + a prerequisites paragraph at the top of the body. Registration pending exactly ONE gate: an operator-approved commit so the convention edits get an immutable SHA to pin.
"Merge sphere1 with box1 so I can sculpt across them" sounds like one library call (three-bvh-csg union). The naive integration measured 918 open edges on a two-sphere union — the library clips each brush against the other INDEPENDENTLY, so along the intersection curve side A subdivides at A's edge crossings and side B at B's: mutual T-junctions by construction. Consequences: get_mesh_stats reports a torn mesh, dig's piercing guard mistrusts the shell, and a fused object can never be union-merged AGAIN (the closed-source pre-check refuses it).
- three's
mergeVerticesCANNOT stitch it — it hashes ALL attributes, and the per-source UV atlas cells deliberately differ at the seam. Weld by POSITION only: snap every vertex in a quantization cell (5× the issue-counter quantum) to the cell's first-seen representative. - Stitch the T-junctions: detect open (use-count-1) canonical edges → find seam vertices lying ON them (parametric projection, perpendicular tolerance) → re-fan the owning triangle through the seam points (positions = the seam vertex's canonical position EXACTLY; UVs/normals interpolated along the OWNING side). Deterministic: fixed iteration order, parametric sort, canonical-id tie-break. 918 → 2 open edges.
- Tolerate trace residuals in the union pre-check (≤ max(8, 0.2%)) so fusion composes with itself.
- Skipping zero-area fan slivers is a false economy: it reopened 6 edges to remove 1 non-manifold edge. Keep the slivers; the sculpt engine's position-keyed weld is indifferent to them.
- Union dirtiness scales with INTERSECTION COMPLEXITY, not source count: 2 spheres → clean; 13 sources → 43 open edges; 18 sources with tangent overlaps → 313 open + 2,770 non-manifold. Tangent/kissing contacts are the poison — the skill's "overlap deliberately 10-20%" rule is load-bearing.
fix_mesh {degenerate}on fused meshes PUNCHES HOLES (2006 dropped triangles → open edges 51→570; independently reproduced 1163 → 43→887). CSG seam slivers are legitimate geometry. Warned in the merge note + skill.- Tangent-face unions can silently inflate triangle counts ~×480 (X-Wing builder: coplanar box faces fused into confetti). Same root: don't union kissing faces, embed them.
- Refine budgets on fresh CSG output run ~3× the formula (coarse union mesh + conformality cascades) — take
nextPassNeedsfrom the refusal. - Multi-candidate
compare_modelscrashed the MCP server ("Connection closed" after 152 s, 2 candidates); single-candidate runs took 320-687 s. compare_models is the new slowest tool in the box — investigate its registration loop before recommending it in-loop. - Outcome evidence the workflow works: owl chamferMeanNormalized 0.0255 → 0.0172 (−33%) with fusion; the falcon's mandibles finally GROW from the hull; the fillet
blendwas cited by every builder as the single best parameter.
- Seam FILLETS (
blend) = Laplacian relaxation of the intersection-curve neighborhood with smooth falloff, seam vertices found via per-source BVH distance (< eps of ≥2 sources). Plain Laplacian is CORRECT here — a fillet wants volume rounding at the crease. - UV re-atlas into per-source grid cells before union: CSG interpolates UVs within each brush, so cells survive, and post-fusion painting never cross-talks between what used to be different objects.
- Drop source paint loudly. Fusion is for the DRAFT stage; texture after fusing is the right order anyway (the field workflow: assemble → fuse → sculpt → texture).
- Union refuses OPEN sources with a teaching error (CSG classification on open shells is silent garbage);
concatmode (concatenate + position-weld) is the never-refusing fallback — the engine's position-keyed weld already makes coincident boundary vertices sculpt together.
A five-front adversarial investigation (refine over-generation, sculpt hotspots, paint hotspots, replay residuals, probe/transport costs) after the field verdict "you should never have to add 100k triangles in a single sequence". Every fix below shipped with before/after numbers; scratch reports in /tmp/perf_agent_[1-5].md.
- Refine flooding was mesh-quality-driven, not parameter-driven. A r=0.3 refine on the shark capsule added 198k triangles — 93% OUTSIDE the requested ball — then deadlocked on an impossible budget refusal. Cause chain: capsule stock ships ~50:1 sliver side-quads (single-segment lathe body) → the ABSOLUTE 12° quality gate red-marked every sliver → red children of slivers are similar to their parents (re-trigger each pass) → the 2-marked closure transported marks region-blind. Fixes: cascade marks clamped to 1.5×r (beyond it, 2-marked triangles emit a conformal 1→3 split), the gate became RELATIVE (a green split not meaningfully worse than its parent stays green), capsule bodies get square quads by construction, and budget refusals diagnose flooding (marked ≫ over-target ⇒ "regularize first"). Shark scenario after: 416 triangles added, no refusal (was 198k + deadlock). Uniform density was also refuted empirically: graded nested rings sculpt identically at 13% of the triangles.
- Live sculpt time was 75-80% advisory surveys (three full-mesh edge scans per command feeding
meshQuality). Fixed: one f64 world-position cache + stamp-AABB box cull per survey, and the third scan derives from the post-survey's own lengths. The advisory feeds the remesh:auto trigger, so everything stays bit-identical (multiset-equal lengths verified). - Paint's candidate scan re-transformed every triangle per stamp (46M reads for a 64-stamp stroke on 240k). A per-command Float64 centroid cache keeps candidate selection bit-identical at ~20× less cost. clone_paint gained mirror_paint's 600M correspondence budget + per-texel centroid prefilter (a r=0.4 heal ran 119 s with no way to know it wasn't hung).
- SwiftShader canvas readback sync: 23.5 SECONDS. A 2048² paint layer created without
willReadFrequentlystays GPU-backed; the first getImageData after any draw forces a multi-second sync (measured 23.5 s for a 64×64 read; 2 ms with the flag). Every canvas that BECOMES a paint layer now passes{willReadFrequently: true}. This single flag was most of the "first paint is frozen" pain. - Raycast/pick were linear scans while a BVH library sat in the bundle. three-mesh-bvh was already a dependency (symmetry, heatmap) but never wired into
castInto. Now: lazycomputeBoundsTree({indirect: true})per geometry keyed on geometryRev.indirectis LOAD-BEARING — the default build reorders the geometry index, and several algorithms break ties by triangle iteration order (determinism). Morphed/skinned meshes skip acceleration (the BVH indexes BASE positions; three's default raycast handles displaced surfaces — caught by the morph suite). 25.9 → ~0.9 ms/ray measured. - Replay divergence P0: object ids survived unload.
_clearAllObjectsnever reset_nextObjectId, so any replica rebuild after an unload (backward seeks, re-joins) addressed objects by ids that no longer matched — 438/510 commands silently failed. One line: ids restart at 1 on clear. - Transport was innocent: MCP protocol tax ~20-52 ms/call (batch amortizes it), SSE streaming ~1% of replay, telemetry microseconds. Screenshots are sync-bound, not pixel-bound (192²≈1024² cost; each NEW size pays a 2.4-28 s warm;
ssao:falseis NOT faster — docstring claim refuted and removed from guidance).
Profile before optimizing, and profile the EPILOGUE (finalization, advisories, readbacks, syncs) before the algorithm. Four of the seven findings were per-command bookkeeping, not the core math. And when a budget refusal quotes an absurd number, treat it as a diagnosis of the INPUT (mesh quality, region shape), never as a dare to raise the budget.
Observation-seat catch-up and recording replay of sculpt-heavy sessions were reported "extremely slow" (2026-07-14). The command COUNT was not the issue — the per-command finalization was: computeVertexNormals() over the whole geometry after EVERY replayed stamp (O(V) each, ~200 ms on a 60k-vert mesh), plus three full-mesh edge surveys per sculpt that only feed a returned advisory nobody reads during fast-forward.
Mark normals dirty during bulk replay instead of recomputing; EVERY reader calls ensureFreshNormals() first (sculpt direction defaults, inflate per-weld normals, refine/simplify/split attribute copies). Any command that needs normals sees exactly the values eager recompute would have produced — determinism verified by bit-identical position AND normal hashes across live vs bulk paths on a mixed sequence including a topology op. Settle all dirty geometries once in endBulkReplay BEFORE the first visible frame. Result: 8.6× on the sculpt benchmark.
Microtask-chained replay never yields a frame: progress banners UPDATE but never PAINT, so even a fast catch-up reads as a frozen tab. Yield (setTimeout 0) every N commands and move the progress UI — perceived speed is part of speed.
When replay is slow, profile the per-command epilogue before touching the pipeline. Deferral is only legal with read-through freshness at every consumer — a deferred cache with even one stale reader silently diverges replicas, which is worse than slow.
Agent builds kept defaulting to primitive assembly + paint because sculpting "didn't work": pulls landed in air, fins came out as accordion folds, tapers touched nothing, and each bilateral feature cost double commands that drifted apart.
- Brushes select by surface proximity — a
pinchcentered on the medial axis of a 0.32-radius body gathers nothing. Every shaping brush must be CENTERED ON THE SURFACE (raycast-probed), pulling toward/away from the axis. - Pulls move the surface — the second pull at the same coordinates sculpts where the crest WAS. Extrusions (fins/ears/limbs) only work as probe → pull → RE-PROBE loops with guarded acceptance (reject re-probe hits that jumped to another feature).
- Sharp falloff on thin features crumples them — a membrane pulled with
falloff:"sharp"folds like an accordion because the falloff cliff shears adjacent rings. Pull with smooth falloff at descending radii, THENpinchto thin the blade, then smooth the base seam. - Hand-mirrored bilateral calls drift and double cost — fixed in the engine:
symmetry:"x"|"y"|"z"mirrors stamps across the object's local plane with direction/pivot/axis reflected and hinge angles negated (reflection conjugates rotations). Refinement density must be mirrored too, or the reflected stamp lands on coarser facets and the sides diverge.
The sculpt loop for SHAPES (not just detail) is: silhouette render → probe → soft pull → re-probe → … → pinch/sharpen → regularize → next feature. Assembly is for machines; stock + brushes is for anything alive. Both are legitimate; the skill's shark playbook carries the transferable sequence.
For weeks, live demos and performances (portrait repaint, Death Star, Mickey) drove the viewer's control API directly in a headless page — same commands, same observe hub, but not the MCP protocol path. Those runs proved the engine; they proved nothing about the product, which is "any external agent can build/edit 3D scenes through meshvault-mcp + the AI skill".
Running the same class of builds through the real stack — a stdio MCP client (X-Wing, Falcon agency) and a genuinely foreign agent (LM Studio pilot, qwen3-next-80b) — surfaced failure modes the expert-driven direct-page sessions could never hit:
- Colors as
[1,0,0]arrays painted WHITE while returning ok:true (String() coercion → THREE.Color fallback). An expert never passes arrays; a local LLM did immediately. Fixed: string params refuse arrays/objects with a teaching error. - Invented actions caused 60-step retry ping-pong (
set_object_color↔list_commands). Fixed three ways: intent-keyword suggestions in the unknown-action error, a loop breaker in the agent runner, skill golden rule #0 (never repeat a failing call verbatim). set_active_objectneeded anamepath — conversational agents think in names, not ids.- Prompt size is a latency budget on local models: embedding the full skill cost 60–140 s per turn on an 80B; golden-rules-only cut a smoke task from 17 min to 2.6 min.
- Protocol-level constraints (MCP result truncation, image content blocks, JIT model loading) that page.evaluate scripts bypass entirely.
- Every demo/performance/experiment script goes through
meshvault-mcp(the stdio scaffold in the skill's Session setup). Driving the page directly is allowed only for engine debugging, never as evidence a capability "works for agents". - Keep BOTH kinds of field runs: scripted expert runs validate the surface's ceiling; foreign-agent runs (local pilot) find the failure modes experts never trigger. The second kind is where the teaching-error work comes from.
- A capability does not exist until it has been exercised end-to-end through the MCP by something that is not its author.
glTF 2.0 defines the texture coordinate origin TextureLoader assets, and relies on texture.flipY (WebGL UNPACK flip) to reconcile image row order vs UV convention.
When exporting to GLB with Three.js GLTFExporter, a common failure mode is vertically flipped textures (and/or “UVs look wrong”) because GLTFExporter flips image pixel rows whenever texture.flipY === true.
GLTFExporterflips the image pixels (via a canvas transform) whenflipY === true— it does not modify UV coordinates.TGALoaderreturnsDataTexture-style images and setstexture.flipY = true.- glTF 2.0 spec explicitly states the UV origin is upper-left.
- Export with glTF convention:
- Flip UV V component on export (
$v \leftarrow 1 - v$ ) ONLY when the UVs are in GL convention — announced by the material's textureflipY: loader-sourced textures (OBJ/FBX/TextureLoader/paint layers) areflipY = true= GL convention → flip; GLB-sourced textures areflipY = false= ALREADY glTF convention → do NOT flip (an unconditional flip double-converts and scrambles every glTF→GLB round-trip into a mosaic — found by a cycle-4 agent's reload proof, 2026-07-11; tangent-handedness negation follows the same condition). - Force exported textures to
flipY = false
- Flip UV V component on export (
- For
DataTexture-style sources (image.data), convert to a canvas in top-to-bottom row order before export so the PNG bytes match the original image orientation.
This yields GLB textures that match the original images (no vertical flip), and a GLB that round-trips in MeshVault without UV/texture coordinate artifacts. Regression guard: a round-trip test compares the exported TEXCOORD_0 min/max V against the source's.
When loading glTF without vertex tangents, Three.js GLTFLoader may clone materials for “derivative tangents” and flips material.normalScale.y *= -1 as part of its internal tangent-space conventions. This is expected behavior and can affect “pixel-perfect” comparisons if one pipeline produces tangents while another doesn’t.
"Recompute smooth normals" and "Simplify" both need to merge coincident vertices so shading/decimation operate on shared topology. The naive approach deletes the uv attribute before merging (so faces merge across UV seams). But BufferGeometryUtils.mergeVertices merges only vertices whose entire attribute tuple is equal — so deleting UVs and merging, then never restoring them, silently strips texture coordinates and leaves textured meshes untextured.
- Delete only the per-face-derived attributes before merging:
normalANDtangent. Faceted duplicate vertices differ only by these, so removing them lets duplicates collapse; if either remains, the merge refuses and smoothing/decimation fails. - Keep
uv.mergeVerticesthen merges vertices sharing position+UV, so a genuine UV seam correctly stays split (two vertices) while non-seam edges smooth. Meshes without UVs merge purely by position — identical to the old behavior. SimplifyModifier(r170) carriesuv/normal/tangent/colorthrough decimation, so preserving UVs beforemodify()keeps textured meshes textured.- Always
dispose()the geometry you replace (child.geometry = merged; oldGeo.dispose()) — reassigning the reference alone leaks GPU buffers.
The original arbitrary read/write/delete exposure came from each endpoint calling Path(client_string) directly. Patching endpoints one by one is fragile — the next endpoint forgets. The durable fix is a single PathGuard.resolve() that every filesystem endpoint funnels through, plus ASGI middleware for auth + Host allow-list. New features (thumbnails, index, tags) inherit confinement because they must call the same guard.
- Confinement rule is general: resolve symlinks first (
Path.resolve(strict=False)), then require the real path to be inside an allowed root. This blocks traversal, absolute-path injection, and symlink escapes with one invariant — not per-attack string matching. - The endpoint predicate MUST match the code path that actually runs.
/api/exportwas breakable because the endpoint branched onis_in_archive && archive_pathwhileExportManageronly takes the archive path wheninner_pathis also set — so an archive request with noinner_pathfell through to a filesystem copy of unguardedrelated_files. Guard for the branch that will execute, and guard every path a copy reads from (source AND related_files). - Canonicalize before persisting keys. The index stores resolved paths; tags/collections stored under a non-canonical path (e.g.
/tmpvs/private/tmpon macOS) succeed but become unfindable. Resolve to the canonical form on write. - Don't accept tokens via query param (
?token=): they leak into access logs, history, andReferer. Use anHttpOnly+SameSite=Strictcookie (set on the app shell) for the browser and headers for programmatic clients. - Host allow-list must fail closed: an empty/missing Host must be rejected, not allowed through.
- Disable
/docs,/redoc,/openapi.jsonfor a local single-user tool — the schema is free reconnaissance.
FastAPI runs async def handlers on the event loop and def handlers in a threadpool. Endpoints that do blocking work — archive extraction (subprocess up to 120s), RAR inspection, FBX conversion, SQLite queries, rglob texture scans, large file copies — must be plain def so one slow request doesn't stall every other request. Only genuinely async work (await file.read(), spawning a background thread) stays async def. Long scans (library reindex) run in an explicit background thread and commit in small batches so writers (tag/collection edits) can interleave instead of waiting for the whole walk.
For materials that get their environment from scene.environment (i.e. no own envMap —
the normal case for loaded glTF), the r170 renderer overrides the material's
envMapIntensity uniform with scene.environmentIntensity every frame:
WebGLMaterials.refreshMaterialUniforms → if (material.isMeshStandardMaterial && material.envMap === null && scene.environment !== null) envMapIntensity.value = scene.environmentIntensity. Setting material.envMapIntensity in this case is a
silent no-op (measured: 0.0 mean pixel diff). material.envMapIntensity only applies
when the material has its own envMap.
- All IBL enable/intensity control must go through
scene.environment(null to disable) andscene.environmentIntensity— one place, applied in_applyEnvironment(). - Never zero
envMapIntensityacross a model's materials to "disable IBL": it does nothing for scene-env materials, and it would wrongly kill reflections for assets that ship their own bakedenvMap. - A procedural
RoomEnvironment→PMREMGenerator.fromScene()gives credible studio IBL with zero shipped assets (offline/Pages-safe). Dispose the temp room scene, the PMREM generator, and the render target (destroy()). - The matte "solid" (clay) inspection mode must suspend the environment: full-strength IBL washes a matte white override to ~230/255 mean luminance and destroys form reading.
frontend/vendor/{draco/gltf,basis} are copied from
node_modules/three/examples/jsm/libs/ and must be re-synced whenever three is
bumped — loader JS and decoder wasm are a matched pair. They are committed (source of
truth), served at /static/vendor/ by the app, copied to web/vendor/ by
scripts/build.mjs for local static serving, and shipped as site/vendor/ by the Pages
workflow. Decoder paths resolve via the viewer's assetBaseUrl option (/static/ in the
app, page-relative in the standalone bundle), so embedders hosting the bundle elsewhere
must pass their own assetBaseUrl. DRACOLoader/KTX2Loader spawn worker pools: create
them once per viewer, reuse across loads, and dispose() them in destroy() (verified:
workers bounded at 8 across 10 loads, 0 after destroy).
Adversarially validated against trimesh ground truth; each rule below replaced a naive version that produced false positives or missed real defects:
- Watertight/manifold topology must be computed on position-welded vertices (quantize at 1e-6 × bbox diagonal, canonical id per position). Raw indices report every UV/normal seam as a boundary edge — a textured cube would look "full of holes".
- Degeneracy must be judged on RAW positions with a RELATIVE sliver test (|cross|² < 1e-12 × |ab|²|ac|², i.e. sin²θ): an absolute area epsilon flags legitimately small triangles in fine-detail meshes, and using welded ids flags genuinely tiny-but-valid triangles whose corners quantize together.
- Flipped normals: use the SIGNED VOLUME of the winding, not a centroid test. A centroid ray test fails on tori and other shapes whose faces legitimately point "toward the centroid". Signed volume (Σ a·(b×c)/6, translation-invariant for closed surfaces) is reliable — and only meaningful when the mesh is closed (boundaryEdges === 0).
- Budget-gate per-triangle work BEFORE doing it and say so in the output
(
checks_skippedissue) — an agent must never mistake "skipped" for "clean". - Main-thread hot loops must be allocation-light: a
new Vector3per vertex plus string Map keys per edge cost ~0.9 s and ~30 MB of garbage at 292k triangles; scalar math + numeric edge keys (u·nVerts+v) removed the freeze. - Report LIVE numbers, not cached loader stats:
_lastStatsgoes stale after simplify/rotate and the report would contradict its own mesh list. Recompute counts and bounds from the current buffers at call time. - Describe the ASSET, not the display override: render modes swap
child.materialand stash the original on_mvOriginalMaterial; an inventory that reads the override claims a textured model is untextured.
KHR_mesh_quantization stores positions/normals as normalized Int16/Uint16 with the
dequantization scale in the NODE transform (common in KTX2/Meshopt pipelines).
geometry.applyMatrix4(matrixWorld) reads through the accessor (denormalizes) but
writes world-scale floats back into the integer array — overflow garbage that destroys
the model (live-verified: after rotate, bounds exploded to the uint16 range and the
render went blank). Any in-place bake must first convert affected attributes to plain
Float32Array (read via getX/getY/getZ, which decode normalization). MeshVault does
this in _dequantizeVectorAttributes() before _bakeWorldTransforms(). The skinned
sibling of this trap (baking geometry while bones keep transforming it) is still open —
see backlog follow-ups.
Exposing per-mesh centers is NOT enough for agents to inspect parts: camera.near is
sized once per model (0.001 × whole-model frame distance) and OrbitControls'
min/maxDistance are absolute constants, so framing a part smaller than ~1/800 of the
model renders ZERO pixels even with mathematically correct set_camera values
(live-verified: 1 cm screw on a 10 m housing). focus therefore rescales near/far and
the distance clamps to the part, and restores them on reset_camera/whole-model
framing. Also: address parts by stable traversal-order id, not name — empirical dump of
real models showed names are mostly loader garbage ("mesh_0", UUIDs, "(unnamed)").
backend/mesh_compare.py (numpy-only ICP+Kabsch with PCA init, trimmed correspondences,
mirror probe, sampling-noise floor) is the single registration algorithm. It is reached
by the MCP compare_models tool (calls the function directly) and by the app's
POST /api/compare endpoint (pure point-set math — no filesystem, so no PathGuard). The
frontend samples both models it has loaded (sample_points, deterministic area-weighted)
and posts the point arrays. Do NOT port ICP to JS — one algorithm, no drift; the ~1-2 s
round trip is fine for a click-driven op. compare_point_sets returns matrix4
(column-major) so the app can apply the alignment to a three.js group directly.
frontend/js/viewer/heatmap.js paints each vertex of the displayed model by its
closest-distance to another (registered) model's surface, via a three-mesh-bvh BVH
(app bundle only — not the standalone/agent bundle). Two load-bearing lessons:
- Build the BVH by reading the OTHER model's positions through
fromBufferAttribute(accessor), NOTgeometry.applyMatrix4/raw.array: quantized/interleaved attributes (KHR_mesh_quantization Int16) overflow otherwise ("offset is out of bounds"). Same trap as_bakeWorldTransforms— see the quantization entry above. - Use
MeshBasicMaterial(unlit) for the ramp, not a lit material: a deviation colour must read identically regardless of scene lighting, or a shadowed red patch looks like a lit blue one. Ramp floor at ~1% of the bbox diagonal so compression/sampling noise reads as "matches" (cool) and only genuine edits light up.
The MCP server and the app are separate processes; the "shared session" feature (backlog 043) bridges them with a session file + one POST endpoint + SSE fan-out. Load-bearing details:
- Discovery via a 0600 session file (
~/.meshvault/app_session.json, {url, token, pid}) — same sensitivity as the launch banner that already prints the token; readable only by the OS user who already owns the server. Removal is pid-checked (lifespan- atexit) so an old instance's exit never deletes a newer instance's file.
- EventSource cannot set headers — the SSE endpoint must live under
/api/*and be authenticated by the session COOKIE (sent automatically same-origin). Same-origin policy: the cookie is HttpOnly+SameSite=Strict, butGET /re-issues it on every shell load, so cross-site deep-link navigations still authenticate the SPA's fetches. - Starlette's TestClient cannot consume an infinite StreamingResponse through
BaseHTTPMiddleware — iterating the stream deadlocks the test (observed: full-suite
hang). Test streaming endpoints against a REAL uvicorn server in a thread
(
tests/test_agent_bridge.py::test_events_stream_delivers_push_real_server); keep TestClient for the non-streaming routes. - Bounded per-client queues, drop on overflow — a wedged tab must not grow server memory; the SSE generator (not the publisher) owns disconnect cleanup, and 15 s heartbeats guarantee a dead connection surfaces as a send error.
- Validate the camera payload at the boundary (422 on malformed) — the message is relayed verbatim to app tabs; a silently-partial camera apply would leave human and agent looking at DIFFERENT views, which is the exact failure the feature exists to remove.
- Server paths are canonicalized (
/tmp→/private/tmpon macOS, symlinks): a deep link's caller-spelled path can differ from the browse response's canonical path for the same file.findAssetmatches exact key first, then basename within the browsed directory (still exact — one directory cannot contain two entries with the same basename).
Loaders resolve when the GEOMETRY parses; MTL/FBX texture fetches keep running after.
_enhanceModel runs immediately on the parsed object, and the old _isUsableTexture
returned false for any texture whose image hadn't arrived — so every in-flight
http(s) texture was silently stripped. Over loopback (MCP/headless) the mesh ALWAYS
wins that race → every OBJ+MTL/FBX model rendered untextured; in the app it was a
real race for small models. An earlier fix had already special-cased blob:/data:
images (FBX embedded textures) — the general rule was hiding in that special case.
- A texture with no image yet is pending — keep it; its onLoad flags the upload.
- Broken is only knowable as: image COMPLETED with zero natural size (decode/404 on an attached element), or still image-less after the load has settled.
- Cleanup of genuinely failed slots happens in ONE place: a janitor pass ~8 s after
the model is added (
_sanitizeObjectTextures(object, settled=true)), guarded bythis._currentModel === objectso a replaced model is never touched. Falling back to base color beats sampling an unbound texture (renders black).
- MCP/loopback:
/models/<token>/<name>— the token maps to the file, siblings are served from its directory (resolve +is_relative_toconfinement). A bare/models/<token>URL gives relative refs no base; everything companion 404s. - Standalone viewer default resolver: relative refs resolve against
viewer.getModelBaseUrl()(the model URL's directory), not the host page — that is what the platform loaders do natively for.gltf→.bin. - Companion discovery is format-aware and bounded: OBJ declares its libraries
(
mtllib— parse it, don't glob), FBX doesn't (bounded texture scan, basename matching), glTF needs nothing (loader-relative). - SSE breaks uvicorn's default graceful shutdown — an
EventSourceconnection never closes on its own, and uvicorn waits for active connections indefinitely, so Ctrl-C/SIGTERM hung while any app tab was open (live-verified). Fix:timeout_graceful_shutdown=3in the uvicorn config — linger briefly, then force-close stragglers. Any future long-lived endpoint inherits this protection. - Publish discovery state only after the bind succeeds, and pid-probe on read
(external tester finding, 0.4.0). Writing the session file before
uvicornbound the port meant a launch that failed to bind (port taken, reaped by a supervisor) left a file pointing agents at a port owned by a DIFFERENT (older) instance — hours of 404 chasing. Fix pair: a watcher thread pollsuvicorn.Server.startedand writes the file only then;discover_app_session()probes the publisher pid and raisesStaleSessionError(pid)(removing the file) when it is dead. SIGKILL cannot run cleanup — only read-side probing covers it. os.kill(pid, 0)is a liveness probe ONLY on POSIX. On Windows, Python implements non-CTRL signals viaTerminateProcess— a "probe" would KILL the target._pid_alive()therefore probes only whenos.name == "posix"and trusts the file elsewhere (worst case: the connection error surfaces the problem, as before).PermissionErrorfrom the probe means "exists, other user" → alive.
Backlog 042 turned the single-model viewer into an N-object registry without touching
~90 existing _currentModel references. The load-bearing rules (each one broke in
design review before it could break in production):
_currentModelis a DERIVED getter over the registry (active entry's model); there is deliberately no setter — every mutation goes through load/add/remove/clear. The one write site everyone forgets is the CONSTRUCTOR (this._currentModel = nullthrows on a getter-only accessor in strict mode). Identity checks change meaning under a getter: the texture janitor's_currentModel === objectbecame a registry-membership test, or co-loaded objects lose their texture cleanup.- Placement lives on a wrapper
GroupOUTSIDE the model subtree; vertex bakes are wrapper-RELATIVE (wrapperInv × child.matrixWorld) and all their box/pivot math must be wrapper-LOCAL (post-bake, subtree transforms are identity ⇒ geometry bounding boxes ARE local coords).Box3.setFromObjectis world-space and folds the wrapper back in — under a rotated/scaled wrapper, ground/center would shift along wrong axes by wrong amounts. Bakes REFUSE skinned models (zeroing bone nodes corrupts the bind pose — pre-existing documented corruption, now blocked). - One monotonic load id cannot express add-vs-replace. Replaces race replaces
(newest wins,
_loadId); adds race the SCENE (generation counter bumped only by clear-all: an in-flight add discards itself if the scene was replaced, and adds never cancel each other). - Per-entry state or stale-state bugs multiply by N: animation ({mixer, actions, clips} per entry; only the ACTIVE entry's mixer advances → others freeze and resume without reset), reset snapshots (retaken after geometry-REPLACING ops — restoring an old positions array into a new differently-sized geometry was the "offset is out of bounds" crash), modelScale/modified as derived getters.
- Exports read the material STASH, never the live material (
_mvOriginalMaterial || material) and authored opacity (per-object ghosting records a backup inuserData._mvViewerOpacityBackup): a clay-render export was shipping clay materials into GLB assets — viewer display state must never enter asset data. - Everything sized "to the model" must size to the visible-UNION box (lights, shadow camera, grid, fog, nav speed, clip planes, measure markers/raycasts), and it must refresh on composition changes (add/remove/transform/visibility), not just on framing — or placed objects silently lose shadows. Offscreen SCORING passes (best view, auto-upright) must hide non-active wrappers or neighbors contaminate the edge-energy scores.
Built against three adversarial design reviews and three live artist-agent sessions. The rules below each prevented (or fixed) a real, observed failure.
new Float32Array(posAttr.array) snapshots raw storage: for KHR_mesh_quantization
attributes that's Int16 integers, for interleaved attributes it's the whole
stride-packed buffer. Any later layout conversion (dequantize/de-interleave — which
sculpt and bakes do) makes the restore write ±32767-range garbage into decoded
float buffers (model destroyed), or silently no-op. Snapshot through
getX/getY/getZ and restore via setXYZ — decoded floats are layout-independent.
The same rule as the heatmap lesson: never read or write attribute.array
directly when the attribute could be quantized or interleaved.
- Weld before displacing. UV/normal seams duplicate vertices at identical positions; per-vertex normals differ across the duplicates, so inflate/smooth on raw vertices TEARS every textured mesh at its seams. Quantize positions (1e-6 × bbox diagonal), compute one displacement per canonical position, write it to all duplicates. This also gives non-indexed (STL) meshes adjacency for free.
- Dedup shared geometries per stamp. GLTFLoader instancing shares one BufferGeometry across meshes — a traverse-per-mesh stamp displaces the shared buffer once per instance (2× displacement), not "each instance once".
- World-space brushes, position-space transforms back. Falloff on world
positions (correct under non-uniform wrapper scale); map the DISPLACED WORLD
POSITION back through the mesh's inverse matrixWorld. Never
transformDirection()for displacement vectors — it normalizes and destroys magnitude. - Finalize once per COMMAND, not per stamp: computeVertexNormals + bounds +
stats after the last stamp of a stroke. Recompute
boundingSphereor the nextpick/raycast misses the sculpted region (Raycaster culls by it). - Missed brushes are ERRORS that teach ("check center — use pick/get_bounds"), and mutation returns carry quantified feedback ({affected, maxDisplacement, newSize}) — a SwiftShader verification render costs 10-60 s; numbers are free.
- Blend in sRGB bytes.
THREE.Colorcomponents are LINEAR working space (r152+ color management): blendingcolor.r*255into a canvas paints darker than requested (#00aa00 → rgb(0,103,0)). Convert viagetHexString()first. - Per-call MAX-alpha accumulation, single blend pass. Rasterizing per-triangle
directly double-blends texels straddling shared edges (the barycentric edge
tolerance overlaps them) → plaid/moiré at low opacity; overlapping stroke stamps
compound the same way. Accumulate texel→max(alpha) across the whole call, apply
once: painter's per-stroke opacity semantics (
opacity= the call's alpha cap). - Triangle rasterization in UV space with WORLD-space falloff — continuous coverage at any mesh density (vertex-splatting gaps on sparse meshes) and each triangle owns its UV island pixels.
- Stock primitive UVs are not paintable. three.js BoxGeometry maps ALL SIX
faces to the full [0,1]² square (cylinder/cone caps overlap the side band):
painting one face paints all six. Remap per-group UV atlases at creation
(box 3×2, cylinder/cone side band + cap islands), then
clearGroups()for single-material rendering. - The paint layer inherits the replaced map's
flipY(GLB textures false, loader textures true) and the splat row isflipY ? (1-v)·H : v·H— wrong orientation V-flips the base layer. CanvasTexture colorSpace = SRGBColorSpace. - Clone-on-first-paint for shared materials (across ALL scene objects, not just
the active one) or painting one mesh repaints its siblings; keep the pre-paint
map/color for
clear_paint(paint must have an undo path); budget painted texels per session (~16M) and return the budget on object disposal. meanAlphain every paint result:painted(texel count) alone reports success for visually-null paint (low opacity × soft falloff); the mean applied alpha catches it numerically before the agent wastes a render (observed: the T1 artist burned ~8 calls diagnosing invisible paint).- Square stamps need an anchor frame: nearest-triangle normal → tangent plane →
Chebyshev distance = crisp axis-aligned quads;
max_normal_angle(dot of face normal vs anchor normal) stops paint wrapping around hard edges — both came from an artist session hand-building a checkerboard out of 36 round stamps per cell.
Screenshots default 1024² but the live canvas is 4:3 — unprojecting screenshot
coordinates through the live camera lands up to ~15% off near edges. pick takes
the screenshot's width/height, temporarily sets camera.aspect, and restores.
Picks are only valid until the camera moves (re-pick after orbit).
The rAF loop renders only when _renderRequested (input/damping/animation/FPV) and
parks after ~45 idle frames — measured 0.0% CPU for the whole browser tree at idle.
Load-bearing details: reset clock.getDelta() on loop resume (or animation jumps),
invalidate() from OrbitControls "change" (damping settle frames), every mutating
control-API command invalidates once (the API layer does it centrally), captures
invalidate afterward (the canvas holds a capture-sized frame while the loop is
parked), shadow maps autoUpdate=false + needsUpdate on invalidate. Sculpt
strokes must NOT invalidate per stamp — a SwiftShader composer render is
~100-300 ms and a 64-stamp stroke would turn into a 20 s op.
- Reset snapshots are LAZY (taken at first mutation via
_ensureResetSnapshot) — an unmodified model never pays the ~35-40% geometry-RAM duplicate; geometry- REPLACING ops (simplify/recompute) DROP the snapshot (new baseline) instead of eagerly retaking it. - Timer closures must not capture models: the 8 s texture-janitor timeout held the full model (geometry + textures) even after removal — capture the entry ID and look it up at fire time.
- Never build per-vertex STRING keys for dedup (
${x},${y},${z}≈ 60 B + rope churn each): nested numeric Maps (x→y→Set(z)) are exact and allocation-free per vertex. Multi-million-vertex models turned hundreds of MB of transient garbage into ~nothing. - Idle-close headless browsers that serve ONE-SHOT requests (screenshot API: unload after each response + close after 5 min idle); NEVER idle-close a STATEFUL session (MCP: the scene IS the session).
Built against a 3-adversary design review and 3 live agent gauntlets. Each rule below prevented (or fixed) an observed failure.
The wrapper TRS is always the composition T(p)·T(pivot)·R·S·T(−pivot) folded
to plain TRS (position = p + pivot − R·S·pivot); the LOGICAL placement
{p,q,s} + pivot live on the registry entry. A pivot sub-group between wrapper
and model CORRUPTS _bakeWorldTransforms (the group's transform bakes into
vertices but never zeroes — double application) and splits the pose across two
nodes (screenshot shows a rotated wing, get_object_transform says identity).
Anything that writes RAW wrapper TRS (the gizmo) must re-derive the logical
placement afterwards (_syncLogicalFromWrapper).
- Interpolate {p,q,s} then compose the pivot per frame → a keyed wing sweeps a true ARC about its root; interpolating composed wrapper values cuts chords.
AnimationMixer.setTime(t)multiplies by timeScale — seek(5) at speed 2 lands at t=10, corrupting seek→screenshot determinism. A puresampleTimeline(t)+ synchronous capture is exact by construction.- Playback writes wrapper TRS DIRECTLY —
setObjectTransformper frame would rebuild the grid/axis helpers at 60 Hz (GC storm). Size the rig ONCE at play from the swept keyframe volume. - QUATERNIONS CANNOT ENCODE FULL TURNS: rotation[360,0,0] round-trips to
identity and the segment silently plays NO motion — the >120° quaternion-
delta check mathematically cannot catch exact 360° multiples. Track the
REQUESTED Euler angles per key (
k.e), warn on ≥90° requests that collapse (and on >120° steps), and show requested angles in get_timeline. Advise ≤120° steps (0/90/180/... for spins) — 180° steps are direction-ambiguous. - basePlacement snapshot when an object gains its first track; stop/clear restore it — otherwise transient animation poses leak into manifests. Timeline must be CLEARED on scene replace (dangling "(removed)" tracks leak into exports).
GLTFExporter FORCES trs:true when clips are present, then reads node
position/quaternion/scale — the static path's matrix-only flat meshes export
collapsed at the origin. The animated path builds objNode(TRS) → mesh(identity, geometry baked by nodeInv × meshWorld), names nodes uniquely (mv_obj_<id> —
PropertyBinding resolves BY NAME, first match wins, and silently DROPS
unresolvable tracks), resamples at 30 fps with pivots composed into every
sample, and VERIFIES the emitted channel count from the GLB's JSON chunk.
r170 SimplifyModifier has NO boundary locking — its border "preservation" is a soft cost that erodes under averaged-cost vertex selection. A crack-free regional decimation needs a constrained collapse where locked vertices (region ring + mesh borders + ALL multi-member welds = UV seams/normal creases) never move or disappear; a Melax collapse u→v never moves v, so the output vertex set is a strict subset — paint layers stay aligned with zero touch-up (texels don't move, only interpolation across bigger triangles). Report achievedRatio + locked counts: seam-dense regions legitimately decimate less than requested. LESSON (T3 gauntlet): a cost-function deterrent is NOT a lock. Open-boundary rims (split-cut edges) carried curvature=1 cost but were still collapsible — decimating both sides of a cut independently made the rims DIVERGE into visible gashes with no warning. Open edges (global welded count 1) must be HARD locks like seams; verified: rim edge count bit-identical across a straddling regional decimation.
The same openEdges read 0 / hundreds / 439 across three tools on one mesh:
stats counted welded cracks, inspect_region counted region-truncated perimeter
edges, fix_mesh excluded degenerate triangles from edge multiplicity. Any
metric reported by multiple tools MUST share one definition (welded, whole-mesh
topology; degenerates count toward edge multiplicity until actually dropped) —
or agents cannot detect regressions across operations.
split_object makes the NEW part active (insert semantics), so the next brush
targets the wrong mesh with a generic miss error. Miss errors now raycast the
brush point against ALL objects and NAME the owner ("the point sits on object 3
('body') but the ACTIVE object is 4 ('wing') — set_active_object first").
Camera views batches must frame the SCENE for the same reason (framing a tiny
active part puts preset cameras inside the parent mesh).
Built through a 5-cycle adversarial program (repair quality, proofs, animation persistence). The durable lessons:
A texture "misalignment" has two entirely different repair classes and the
atlas topology decides between them. Coherent atlases (few semantic charts —
one island ≈ one feature) are repairable in UV space: transform_uv with
island_of scoping + preview_uv_transform bleed dry-runs. Fragmented
photogrammetry-style atlases are NOT: the portrait measured 4,424 non-semantic
Voronoi-like islands with the eye and mouth sharing one chart — no affine UV
transform can move the eye without dragging the mouth, and both UV-surgery
attempts were falsified by honest before/after renders. The correct class
there is SCREEN-space: project_paint re-samples the current render at
(screen + offset) for every texel in a world brush, so texel↔feature
correspondence is taken from the SURFACE where it actually exists.
get_uv_islands first; its island count IS the classifier.
Every capability claim in 047 carries a machine-checkable number before any
render: explode_view returns minGapWorld (negative = overlapping pairs — a
separation verdict with no vision pass), transform_uv previews return bleed
fractions, texture-LoD ladders report per-tier texel densities halving, and
animated round-trips verify the emitted channel count from the GLB JSON chunk.
Renders illustrate; numbers decide. Proof packs live outside the repo
(~/MeshVault_assets/proofs/ + INDEX.md) because GLBs and screenshots are
artifacts, not source.
Plane cuts classify WHOLE triangles by centroid: no vertex lies on the plane, and rim vertices scatter up to a full triangle's extent off it. Any "vertices within tolerance of the plane" rim definition is therefore wrong twice — it misses real rim vertices AND catches pre-existing open boundaries the plane grazes (sealing holes the user didn't cut). The correct rim is the set of edges that BECAME open: welded edges with multiplicity 1 on one side and >0 on the other, from the pre-split classification. Pre-open edges never qualify by construction.
Caps that reuse rim indices inherit side-wall normals (shading garbage) and side-wall UVs (stretched garbage), and writing cap UVs onto shared vertices corrupts the walls. Duplicated rim vertices (same position, cap normal, cap UV) fix all three — and as a free consequence the rim positions become multi-member welds, which simplify_region already hard-locks as seams: no new lock category needed after capping.
Ear clipping with strictly-convex ears produces quality caps but legitimately fails on staircase rims with collinear runs (axis-aligned cuts through boxes); zero-area ears would emit degenerate triangles that fix_mesh later drops, REOPENING pinholes. A centroid fan closes every rim edge exactly once by construction (possibly self-intersecting on non-convex loops — cosmetic, not topological). Ship ear-clip → fan fallback, and report the MEASURED post-cap openEdges (shared welded semantics), never the assumption. Winding: orient each loop by projected signed area against the signed classification normal, in MESH-LOCAL space — closing a mesh ARMS fix_mesh's flipped_faces gate, so an inverted cap would flip the whole mesh's winding downstream. Two audit corrections that shipped before the field test:
- EMIT thresholds must EQUAL downstream DROP thresholds: the ear-accept epsilon originally sat ~3 orders of magnitude below fix_mesh's degenerate cutoff (diag × 1e-7) — caps in the gap would be emitted here and dropped there, silently reopening pinholes. Any producer/consumer pair around a degeneracy threshold must share ONE rule.
- Cap UVs collapse to ONE loop-anchor UV. Copying each cap vertex's own rim UV makes cap triangles INTERPOLATE across the atlas — on fragmented photogrammetry atlases that renders as smeared multi-island streaks, the exact "stretched garbage" capping exists to kill. One anchor = one flat sample of the adjacent surface.
- Rim membership is the mod-2 (Z₂) boundary rule, not "exactly one owner".
Scanned doubled-shell geometry has cut edges owned by 3+ triangles; the
naive
sideCount === 1rule left degree-1 dead ends no loop walk can close (a 256-edge permanent hole on the chest re-split). A side's edge BECAME boundary iff its side-count is ODD and the pre-split total was EVEN (odd totals are pre-existing boundaries — still never sealed). Mod-2 boundaries are cycles, so every rim vertex has even degree and a closed-loop decomposition always exists. - Loop walks continue by ANGLE at junctions. Re-cutting through an existing cap crosses the previous rim's welded ring at 4-degree junctions (cap duplicates weld with wall vertices). Rejecting branches as bowties skips whole rims; the planar boundary-tracing rule — leftmost turn in the cap plane from the incoming direction — decomposes tangent/crossing boundaries into simple non-crossing loops. Vertex revisits at junctions are legitimate (each occurrence gets its own cap duplicate).
- Anchor color: median of the rim, not vertex[0]. A fragmented atlas makes the arbitrary first rim vertex a color lottery (an olive-gray cap on a wooden chest when its texel lands off-island). Sample all rim vertices' texels through a small readback canvas (when the base texture is drawable) and anchor at the vertex nearest the component-wise MEDIAN — deterministic and plausible; unreadable textures (KTX2/GPU-only, tainted) fall back to vertex[0].
- UV-degenerate triangles need their own paint path. A cap's collapsed UVs give zero UV-space area: the barycentric rasterizer emits no texels, so caps rejected paint with "Brush touched no surface". Stamp such triangles' single texel directly when the brush touches them in WORLD space (closest point on triangle, not centroid — cap fans are huge and their centroids sit outside small brushes). Painting a cap = flatly recoloring it, honestly reflecting its one-texel nature.
Texture-based morphs cache a DataArrayTexture keyed by morph count —
re-capturing an existing morph (same count, new content) renders the OLD
deltas forever. After any content change: geometry.dispose() (its dispose
listener evicts the texture; three re-uploads next frame). Cheap at capture
cadence.
Vertex bakes (center/ground/rotate/orient) and export-time node-relative baking transform position/normal/tangent only. Morph deltas are position DIFFERENCES: the affine part cancels, so they transform by the bake matrix's linear 3×3 (exact under non-uniform scale; transformDirection would normalize and destroy magnitudes). The morph BASE transforms by the full matrix.
Deltas are relative to an explicit sculpted base (entry.morphBase) — not the
reset snapshot (the artist sculpts a base first) and not the UI undo slot
(overwritten every gesture). Consequently reset (which restores ORIGINAL
positions) must DROP morphs loudly: original + (pose − sculptedBase) is a
shape nobody authored. Same for geometry-replacing ops (vertex indexing
changes). "Morphs survive like pivots" is wrong — pivots don't reference
geometry that reset changes.
Morph weights ride the existing per-object track as flat morph:<name> keys
(nested objects break every Object.values consumer: durations, ticks, export
scans). Flat is necessary but not sufficient — k.v.map(...) crashes on
scalars in get_timeline/serializeTimeline, and delete-all-channels paths that
hard-code ["position","rotation","scale"] leave morph-only tracks undeletable.
When adding a channel KIND, grep every consumer of the channel dictionary.
- Morph GPU cost is per-FRAME, and unbudgeted it wedges the viewer. three's texture-based morph path shades EVERY target on EVERY vertex each frame, independent of weights. 8 full-head morphs on 99k vertices made each SwiftShader render outlive the command timeout — no rejection, no recovery, session dead. Budget vertex×morph products at capture time, sized to the MEASURED renderer (UNMASKED_RENDERER_WEBGL: ~512k software / 8M hardware) with a teaching error (delete poses / simplify BEFORE begin_morph). Sparse-delta budgets don't catch this: the cost is dense.
- Imported glTF morphs are a second morph class: drive-only. Reloaded exports carry dense morphAttributes + mesh dictionaries but no sparse masters — set_morph/keyframes must drive them by dictionary (they did not: the field's "dead end" bug); capture/delete must refuse honestly; and begin_morph must refuse while they exist (rebuildMorphAttributes materializes OUR morphs only — capturing would silently discard the asset's). Guards must check LIVE mesh influences, not just the session weight ledger (paused clips and imported morphs displace the surface outside it). Corollary: updateMorphTargets() no-ops on morph-FREE geometry — clear dictionaries explicitly when dropping morphs, or deleted names stay addressable as ghosts.
- Brushes must aim at DISPLAYED positions under active morphs. pick and raycast are morph-aware (three applies influences on CPU); the paint rasterizer read BASE positions — aim-what-you-see broke exactly on the open mouth (96 texels "painted", nothing visible where aimed). Compute displayed positions (base + Σ weight × delta, relative AND absolute conventions) for paint/heal footprints; refuse base-space donor brushes (blur/clone/mirror) while morphed instead of silently mis-sourcing.
- Every vertex-writing bake must transform the morph base too. rotate/ recenter/ground/auto-orient wrote vertices AFTER _bakeWorldTransforms with their own matrices — the stored base never saw them, so the next capture diffed ALL vertices against a stale base, silently encoding the rotation into the morph. One shared morph-aware matrix-application path for all bakes; as a bonus applyMatrix4 rotates authored normals exactly instead of recomputing (and discarding) them.
- The pose brush the field asked for is a rotation, not a translation.
Radial grabs translate a blob — chins drop but lips smear.
hinge{pivot, axis, angle_deg} rotates the brush region rigidly about a line with falloff: selection (center+radius) and transform (pivot+axis+angle) are DIFFERENT anatomy (chin vs jaw hinge under the ears). One stamp = a believable jaw drop.
mirror_paint maps every destination texel to its reflected world position and
samples the source surface there. That map has determinant −1: it reverses
orientation, so a right eye sampled this way arrives as a LEFT eye (tear duct
on the nose side) with zero explicit image math. Composing an additional 2D
"flip the patch" step — which the original design sketch called for — yields
det +1, an orientation-PRESERVING copy: the donor pasted un-mirrored. Flip
steps belong only to sample-rect-then-stamp architectures; per-texel
correspondence has no rect. (Adversarial review caught this before a naive
implementation shipped it.)
Reflecting world points across a world-transformed plane normal is only valid
under rotation + uniform scale. The correct world-side map is W · R · W⁻¹
(W = wrapper matrixWorld, R = local Householder), and normals go through the
composite's normal matrix (A⁻¹)ᵀ. "Local" means WRAPPER-local — meshes
inside the model carry their own node transforms.
- A metric whose documented trigger is mathematically unreachable is worse
than no metric:
selfSourceFractioncounted mirrors within 10% of the radius, capping it at ~0.06 for an on-plane disc (expected 4ε/π), so the promised "≈1 + note" could never fire. Derive thresholds from the geometry of the definition (mirror lands INSIDE the brush → up to ~0.6 on-plane). - O(texels × candidates) loops need work budgets: a half-head brush ran 6 silent minutes — indistinguishable from a hang, and agents with timeouts kill the session and lose paint state. Budget in naive pair-tests + a centroid prefilter (nearest-conceivable-point rejection) for the real cost.
- Zero-result diagnostics must exclude the thing that just failed: the cross-mesh hint named the object's OWN single mesh and detoured the agent to clone_paint — which refuses mirrored donors by design.
- Detection origins wander with the sample seed (~cm on a head — a smear at iris scale): refine the plane offset along its normal with a 1D median-distance scan; both seeds then converge to the true plane.
Median + p90 reflected-point-to-surface distance (BVH; normalized by the local
bbox diagonal) through the AREA CENTROID (bbox centers are biased by one-sided
features like pedestals), plus mean dot(reflected source normal, hit normal) —
positional scores alone accept planes that map surface onto surface with the
wrong orientation (thin plates). On near-perfect shapes every centroid plane
ties (a sphere mirrors across ALL of them): prefer canonical axis planes over
PCA on ties or the choice is float-noise-random. And geometric symmetry never
guarantees TEXTURE symmetry — verify heals with a render. The BVH cache keys
on geometryRev; MeshBVH construction REORDERS the geometry index, so
triangle lookups by faceIndex must go through the index, never by f*3
position order (a sphere scored normalAgreement ≈ −0.29 until fixed).
The panels instantiate the app's single ViewerControlAPI and route every
MUTATION through it (the E2E suites test execute(), so UI edits are covered
transitively). READS must NOT go through it: every successful execute()
invalidates the renderer, so a per-pointermove hover pick would force
continuous rendering and defeat the parked 0%-idle-CPU loop. Hover and stroke
sampling use a local THREE.Raycaster.
Canvas input consumers are split across MOUSE events (click-select, measure)
and POINTER events (OrbitControls, TransformControls) — stopPropagation on
pointerdown never reaches the derived mouse listeners. A viewer._toolMode
flag consulted by every consumer (precedent: _measureMode) is the only
arbitration that works. OrbitControls can stay ENABLED and be disabled AFTER
a synchronous hit-test: rotation happens in pointermove, which re-checks
enabled per event — nothing leaks, and misses orbit for free. Capture the
pointer on the CANVAS (capturing an overlay starves OrbitControls' dynamically
bound pointerup and corrupts its tracking). The gizmo is guarded by its
hover-set axis property — checkable BEFORE its own pointerdown runs.
navmodechange was dispatched at →orbit call sites only; nothing announced
→fpv, so tool modes never learned about it and a human could paint while
flying (gauntlet). Per-call-site notification IS the bug class ("some caller
forgot"); the state owner (setNavMode) must dispatch on every transition.
The same event then keeps the toolbar icon honest for free when other code
forces a mode.
The UI slices one drag into many stroke COMMANDS (flush cadence). Anything
with per-command semantics silently degrades to per-slice for humans: undo
undid the last 100 ms slice, painter opacity compounded across slices
(0.05 → ~0.44 over a slow wiggle), and gesture-end feedback starved when the
flush timer drained the buffer before pointer-up. The bridge is an explicit
undo_group token on the brush commands: same token = one undo unit AND one
alpha-composition unit (per-texel gesture ledger: eff = (α−prev)/(1−prev)
tops each texel up to the target instead of compounding). Public param —
agents batching a logical stroke across ≤64-point calls get the same
semantics. And run gesture-end feedback at gesture end, unconditionally.
The app's placeholder hid only in file-load flows; objects created through
the control API (agent pushes, primitives) left it covering the canvas — where
it silently ate every pointer event. UI chrome that depends on "is anything
loaded" must track objectschange, not load call sites.
The engine recomputes whole-geometry normals per COMMAND (~5–15 ms at 120k triangles). Flushing per animation frame means 60 recomputes/s — 30–90% of the frame budget, jank exactly while dragging. Buffer radius/2-thinned points and flush at ≥16 points or 100 ms; fidelity is unchanged (stamp density is bounded by the thinning, not the flush rate).
Hierarchy (parent), pivots and timeline tracks persist by OBJECT INDEX in
the manifest's objects array, never by live registry id (ids are
session-local) and never positionally against the loaded result (partial loads
shift positions). Application is two-pass: create everything first, then wire
parents/pivots/timeline through the index→id map, so forward references and
per-object load failures degrade without corrupting the rest.
refine_region (conformal red-green splits: 2-marked triangles close to red,
thin 1-marked children upgrade to red under a ~12° floor) adds facets where
detail is needed; simplify_region removes them where it is not; both lock
seams, open rims and the boundary ring. The third failure class appeared in
the field: heavy grab pulls neither add nor remove facets — they STRETCH them
into long slivers that shade as streaks and smear later paint.
regularize_region closes the trio: 4/3-target conformal splits (reusing the
refine pass, so no new crack class) + Jacobi tangential relaxation (move each
interior weld toward its neighbor average, subtract the normal component —
shape preserved to first order, triangle shapes equalized). Relaxation moves
vertices, so normals recompute on commit (the sculpt rule), and texture
drifts slightly where vertices slid within the surface — disclosed in the
result note, tidied with blur_paint/repaint.
Zero-config regularize targets the region's CURRENT median edge — correct on
a homogeneous region ("equalize at the density you have"). But a brush that
covers both a dense crop and a stretched curtain gets a blended median that
marks tens of thousands of edges "stretched"; the split budget dies before
the relaxation matters (field numbers: 36,303 → 35,659 after a full budget).
Same region with an explicit coarse target_edge: 430 → 8. Statistics
computed over deliberately mixed populations are not defaults, they are
traps; the command description now teaches the explicit-target escape.
The observation seat replays the performer's top-level mutating commands
through the observer's own deterministic engine (per-session ring log,
cursor-based SSE, contiguous seqs, lossy honesty, fingerprint divergence
checks). Native rendering quality for free; the cost is strict determinism —
capture:true keyframes canonicalize to explicit values at publish,
project_paint carries a camera envelope, and auto_orient's power
iteration needed fixed seeds (Math.random() was genuinely non-replayable).
The first ghost-brush design spawned a fading ring per stamp with a floating label sprite — reviewers could not tell WHAT tool was active (labels blinked away with the stamps) and the text cluttered the scene. The shipped model: ONE persistent cursor (ring + additive falloff disc, scaled to the resolved world-space radius = the actual area of influence) that GLIDES through the stroke's stamp points, faint per-stamp trail behind it, and the tool identity (name, radius, strength/opacity, color swatch) in a dedicated panel readout. Rule of thumb: geometry communicates WHERE and HOW BIG; the panel communicates WHAT and HOW STRONG. Text in the 3D scene serves neither.
expose_function callbacks (the observe publish relay, including liveness
pings) are dispatched ONLY while the Python main thread is inside a Playwright
call. A performer that waits by time.sleep() looks alive to itself while the
hub sees its pings stop and honestly declares the session dead. Any wait loop
in a sync-Playwright process must pump (page.evaluate("1")) each iteration.
Async Playwright does not have this failure mode.
The seat panel rendered every session the hub remembered, dead rehearsals
included, each with an enabled Watch button — the one joinable session sat
below the fold and the user "couldn't join" (their words, with screenshot).
Liveness is part of joinability (a corpse session replays a prefix then hangs
forever: the killed performer never publishes end), watchable rows sort
first, dead history collapses to a count, and the list refreshes itself while
open. A list UI whose primary row is findable only by scrolling past garbage
is a broken UI regardless of how correct the data is.
Two field captures from the LM Studio pilot (Qwen 3.6-35B over LangGraph +
MCP): (1) the model batched describe_scene alongside add_primitive in one
turn — with parallel execution the describe raced ahead and answered from the
PRE-add scene; MeshVault is stateful, so the pilot serializes all tool calls
through one lock (read-after-write order is part of the API contract even if
the transport allows concurrency). (2) A sculpt stamp on a deliberately
coarse sphere "succeeded" with affected: 1 — a spike, not a shape — and the
model sailed on happily; quantified SUCCESS is not enough when the number is
quietly pathological. Results now carry advisory notes (under-sampled brush
→ the exact refine_region fix), and the pilot's prompt says "react to notes,
not just errors". With the note in place the same model self-corrected:
refine → re-pick → re-sculpt (1 → 25 vertices) → regularize (59 → 0
stretched), no human help.
The three-adversary review's central finding: every sculpt tool only MOVES vertices, so triangle quality decays monotonically with editing — grab/hinge impose linear signed strain, flatten folds, pinch compresses into the weld quantum, smooth amplifies anisotropy. Splitting alone (the first regularize_region) cannot recover: needles need COLLAPSE, poles need FLIPS. The full Botsch–Kobbelt loop (split 4/3 → collapse 4/5 → flip to valence 6/4 → tangential relax) with the no-new-long-edge collapse predicate (without it split and collapse ping-pong forever) is what "regular coherent graph" means operationally: stretchedEdges → 0 and valence567Share ≥ 0.9.
The observe fingerprint compares EXACT triangle counts. Making sculpt remesh by default would banner-diverge every existing recording replayed through a new engine. New geometry-changing behavior must arrive as an explicit parameter or command (replays carry it byte-for-byte), never as a changed default — and budgets must be counts, never wall-clock, and decision paths must avoid transcendentals (compare squared lengths/signed areas; acos is for reporting) or replays drift by ULPs. A double-replay position-HASH test now guards this (count fingerprints cannot see position drift).
Four proof-run findings in one evening: (1) profile distance must be LATERAL (cylindrical) — with 3D distance, dug welds sit "far" and repeated stamps asymptote instead of deepening; (2) gathering at exactly R sawtooths the rim on curved domes (rim verts alternately miss) — gather 1.15R; (3) gathering WIDE (1.5R) reaches the receding flank of a closed surface — verts whose lateral distance is small only because the cylinder re-enters the dome — and tears it; (4) the back-sheet filter threshold matters: dot > 0 excluded the crater's own WALLS (normals hover near perpendicular) and left un-moved teeth; a true back sheet faces along the axis (dot ≈ +1), so 0.5 separates. And piercing probes must intersect DOUBLE-SIDED: the far wall of a shell faces away from the ray, so front-face-only raycasts report infinite headroom on every shell.
The haircut fields: grab can shorten, compact, bury and tidy a hair shell, but it RELOCATES surface — it cannot delete it. A hanging curtain pulled "away" reappears elsewhere (downward/inward pulls re-bunch into spikes; the working direction is toward the volume interior with an upward bias, sharp small brushes for thin fins). A true "cut everything below this line" needs geometry REMOVAL (split_object + discard), not displacement. Choreograph within the tool class or reach for the right one. Related field lesson: coarse probe detectors converge where fine ones stall — an 11-column ray scan found 123 targets and diluted 26 passes across them; the 5-column scan found the 42 worst and finished in 14.