-
2026-08-11 — The WebXR viewer refuses a lightmap carrier it does not know, instead of treating it as occlusion. The carrier switch special-cased
'fused'— a bake level both DCC tools have since removed, so no producer can emit it — and sent everything else toaoMap. So the one branch that existed was dead while any future or misspelled carrier would be rebound under the interpretation it is least likely to want. Replaced with an{occlusion, emissive}lookup mirroring blendertk'sCARRIERS, refusing anything absent from it: the map then stays in the slot it arrived in, where it still reads as plausible occlusion. AMap, not an object literal, because the lookup decides whether to rebind a material and property access on a literal walks the prototype chain — a manifest namingconstructoras its carrier would hand back a truthy function and slip straight past the refusal. -
2026-08-11 — Scene sidecar v2: textures are referenced by content, not by path, and the envelope now carries its own reading contract. The deliverable was already standalone in the way that matters (every texture embedded; nothing at load time reads a filesystem path), but a section entry named its textures by the path they had on the authoring machine — provenance that resolves nowhere else — so a dev or an agent handed one
.glbhad no mechanical way to get from a section entry to the embedded image it became.Borrowed from the OCI image spec rather than from Dockerfiles: reference content by digest, not by location.
apply_scene_sidecarnow embeds a copy of the envelope carryingtextures—{authoring path: {image, sha256, bytes, mimeType}}— plusvalidate(what the envelope itself claims: entries per section and how many references resolve — deliberately not the file's total image count, since the lightmap applier runs after the sidecar in every production path and a stale total would make a verifying reader reject every lightmapped deliverable). It is derived wholly from the session's embed cache, so it needs no knowledge of any section's shape and a section added later is covered for free; it also splits the ORM packer's composite cache key, sometallic/roughness/occlusioneach resolve — all three to the same image, which is the truth, since the trio is repacked into one. A copy, because the caller keeps their envelope and writes the.scene.jsoninspection file from it.The digest has a shorter lifetime than the index, and getting that wrong would be worse than shipping no digest at all — a reader that verifies would reject a good artifact.
optimize_glb_texturesre-encodes every image it touches, so a digest stamped at apply time describes bytes the delivered file no longer holds. One idempotent stamper reads the envelope back out ofextrasand refreshes the addresses from the current payloads; both the applier and the optimizer call it, so whichever pass wrote the bytes last is the one that addressed them. Image indices are stable across the repack, which is what makes this a refresh rather than a rebuild.build_scene_sidecaraddshandoff=instructions(the reading contract as data in the artifact — a rule that only exists in documentation is not part of the hand-off; it names the trap, that section paths are provenance, and the escape, thetexturesmap) plusreadsandsections, both derived from the existing registries so a manifest or section added later cannot silently fall out of the contract. The text is deliberately declarative about the file's own structure and issues no directives, so an agent can read it as untrusted content safely, and it refers to the asset through the envelope'sassetkey so it reads correctly from the.scene.jsoncopy too.SIDECAR_VERSION→ 2.Also folded out a duplicated resolution while wiring this:
GlbEdit.image_for_textureis now the one place that knows anEXT_texture_webpbinding shadows a texture's plainsource(the optimizer's lightmap-exemption scan had its own inline copy), bounds-checked like itsbase_color_imagesibling. Every DCC consumer was checked and needed no change — they build the envelope and readsections, never the key set. +4 tests. -
2026-08-11 — The AO packed into the ORM's R channel is now actually sampled, and the lightmap's claim on the occlusion slot is gated. glTF reads occlusion ONLY from
occlusionTexture, and nothing ever bound the packed image there —set_glb_metallic_roughness's own docstring anticipated "a later writer" that did not exist. Measured on a delivered preview GLB: 0 of 57 materials sampled their packed AO. The writer now binds the packed image asocclusionTexture(the spec's own packed-ORM idiom — same image, both slots) when the slot is free, repoints a slot still naming the converted ORM it just replaced (FBX2glTF binds its own packing there, so the stale reference sampled the — often solid-white — image the repair had just superseded; 4 of 57 materials on the probed GLB were in that state), and never touches a separate authored AO map.apply_glb_lightmapsin turn distinguishes that binding from authored data by texture-index equality with the material's ownmetallicRoughnessTexture: a recognised ORM is displaced silently (a bake's occlusion, computed with real bounce, supersedes it — and with the writer now binding nearly every repaired material, the old warning would have fired on nearly every lightmapped material and buried the signal it exists to carry), while an authored map still warns. Newreplace_authored=Falsekeeps the authored map and stands the lightmap down for that material instead — gated before the encode-and-embed on both the shared-material and the per-instance-rect paths, so a fully skipped material does not carry the lightmap PNG as an orphan texture nothing samples. +4 tests. -
2026-08-11 — A lightmapped model no longer renders dead flat in the WebXR viewer: switching the viewer's lights fully off took every normal map with them. Reported as "the OpenGL normal maps don't seem to be used". They were: probed on a live preview GLB, 54 of 57 materials carry a
normalTextureattexCoord0 pointing at correctly-named OpenGL-convention maps, and every primitive shipsTEXCOORD_0+TEXCOORD_1. The problem was downstream of the wiring. three.js addslightMapirradiance throughBRDF_Lambert, which has no normal term — a bake supplies light that does not vary with the surface normal at all — so withscene.environment = nulland the key light at 0 (the previous "a lightmapped model already contains its lighting" policy), nothing left in the render sampled the normal: normal maps, roughness variation and every specular highlight went inert. 51 of 57 materials on that scene are lightmapped, which is why it read as a flat model rather than as a lighting bug.The environment now stays on, dimmed to 25% via
scene.environmentIntensity. Environment specifically, and not the key light back: it is omnidirectional, so it cannot contradict the bake's shadow direction the way a directional gradient would, while its view- and normal-dependent specular term (getIBLRadiance, which takes the normal) is exactly what makes a normal map legible. The key light still goes to 0. A Light button (andl) togglesbake + env/bake only— that comparison is what tells a flat-looking model caused by a bad bake apart from one caused by the viewer's own lighting policy, which is the confusion that produced the report.The dimming has to be scene-wide, and finding out why is the part worth recording. The first cut set
material.envMapIntensityper lightmapped material, so un-baked props in a partly-baked scene would keep full lighting — and it did nothing at all. Checked against the shipped three.js 0.169.0 source rather than assumed: the renderer overwrites that uniform from the scene value for anyMeshStandardMaterialwhoseenvMapisnull—if (material.isMeshStandardMaterial && material.envMap === null && scene.environment !== null) m_uniforms.envMapIntensity.value = scene.environmentIntensity;— which is every material GLTFLoader produces. Opting out would mean assigning each material the shared PMREM texture as its ownenvMap, whichdisposeModel(it disposes every texture-valued property it finds) would then destroy on the next push, taking the environment down for the rest of the session. Scene-wide it is; un-baked geometry in a mixed scene renders cooler, not unlit. +1 test pinning the level as declared, non-zero, and applied through the scene property. -
2026-08-11 — Documented the WebXR preview pipeline end to end (
docs/webxr_preview.md, wired from the pythontk / mayatk / blendertk docs hubs): the ownership split acrossPreviewServer/PreviewDeliverer/PreviewBridge/MeshConvert/ the viewer, how a bake is carried through a format with no lightmap slot (and why occlusion is the carrier), what the scene sidecar holds and how a dev holding only the GLB reads it back, why a packed MSAO mask works without authoring ORM, and the measured budget — including that the transport half is solved (94.7 MB → ~15 MB on the profiled room) while GPU memory is now the binding constraint on scene size in a headset: a delivered 9.5 MB preview GLB decodes to ~555 MB of RGBA, ~740 MB with mips, becausemax_sizeis a per-image ceiling and nothing budgets the total. -
2026-08-10 —
MapCompositor's output-template pass collects its progress ticks instead of logging one record per set. Each log record is its own paragraph in a text-widget handler, so the per-set[i/n] messageline turned a 20-set batch into 20 blank-line-separated sections in the Compositor panel. The ticks accumulate and go out as onelog_groupin afinally, so a batch that dies partway still reports the sets it prepared. The rest of the file already usedlog_group; this callback was the holdout. -
2026-08-10 —
FileUtils.is_rooted_path/resolve_output_dir: one definition of "a directory field holds a subdirectory name or a full path". Three panels had grown their own copy of the rule — Optimize's Destination fields, and the two lightmap bakers' new Output Directory — and only the first had it right. The subtle half isos.path.isabs, which calls a rooted-but-driveless"/new"absolute on Windows and then resolves it against the current drive's root: a user spelling the subdirectorynewwith separators silently lands outside the project.is_rooted_pathrequires a drive or UNC share (Windows) or a leading/(POSIX), andresolve_output_dir(entry, base)builds the whole rule on it — empty means base, a full path wins outright, anything else is a subdirectory of base, with the entry treated as typed by a human (surrounding whitespace and the quotes a path picks up when pasted out of a file manager are stripped,~and environment variables expand, and a bare entry's own leading/trailing separators are dropped so"/new/"and"new"name the same folder). It never returns a relative path: the caller's eventualos.makedirswould create that against the process CWD, which in a DCC is wherever the app happened to be launched from — so with no base to resolve against it returnsNoneand the caller falls back to its own default. +14 tests. -
2026-08-10 — Two rival packings no longer both survive
filter_redundant_maps: a glTF 2.0 conversion kept its ORM and an HDRP mask map, and wired both into the same three slots. Reported from a live conversion whose report listedORMandMSAOon every material. Redundancy precedence is keyed offMapType.replaces, which names the LOOSE maps a packing absorbs — and no packing names another, so two packings never met there. The loose pass then actively hid the collision: the requested ORM retired the loose Metallic/Roughness/AO first, after which the MSAO found no loose components left and took the "sole source of its channels" branch. Both were connected, the second wiring won per plug, and_ensure_fbx_safe_connectionpinned the loser's file node so the foreign mask map rode into the FBX → GLB as dead payload — while the exporter's ownforeign_packingsgate was, correctly, complaining about it. The two halves of the pipeline disagreed because only one of them had ever been taught the question.New
MapRegistry.packed_precedence(config)answers it, as a total order so the survivor never depends on registration order or on which rival happened to be judged first: requested by name (the preset sets the map'sconfig_key) > requested only byforce_packed_maps> declared breadth (ORM ships to UE/glTF/Godot, MSAO only to HDRP) > registration order. Separating the two request levels matters —force_packed_mapsmeans "emit a packed map even with a source channel missing", and letting it confer full request status was a second route to the same double-wire.MapFactory._resolve_packed_conflictsruns as a pre-pass, so every existing caller (both DCCs' Game Shader and Mat Updater, the compositor) gets the fix with no call-site change. Rivalry is judged by channel coverage, not by name: a packing is a rival only where a more-preferred one covers a channel it carries, soAlbedo_Transparency(base colour + opacity) is never weighed against an ORM. Coverage needed no new machinery — the conversion registry already declaresRoughness ← ORMandSmoothness ← ORM, so widening the existing single-source-conversion test from loose maps to any survivor is what makes a packing demonstrably cover another's channels; that test is now the shared_channel_coveredboth passes read, so "covered" means one thing. Lossless in the same way the loose pass is: channels the winner cannot supply are extracted from the loser first (URP keeps its Metallic_Smoothness and recovers the MSAO's AO), and when extraction is unavailable the loser is kept with a warning rather than taking its only copy of a channel with it. The two judgements read different sets, which is not a detail — rivalry is decided against the winners alone (crediting a loose map there would make an ORM beside a loose Metallic look like a rival of whatever outranked it), while what must be extracted also credits surviving loose maps, because re-deriving a channel one already supplies swaps the caller's authored map out of the inventory for channel data. Measured: an MSAO beside a Metallic_Smoothness and the caller's ownasset_Mixed_AO.pngwrote a redundantasset_Ambient_Occlusion.pngand wired that instead — the existing on-disk guard misses it precisely because the caller's file need not sit under the canonical name.Two consequences found by the fix rather than assumed. The loose pass now considers loose redundants only:
MSAO.replaceslistsMetallic_Smoothness— a packing — so it could retire a rival on name alone, with no ranking and no coverage check, and overturn a keep-both decision the new pass had made deliberately to avoid losing a channel. And coverage for the extraction question has to credit surviving loose maps, not only the winners:{Metallic_Smoothness, MSAO, Ambient_Occlusion}under URP extracted the MSAO's AO channel even though the caller had listed their own AO map, swapping that inventory entry for derived data —_extract_channels_from_packed's on-disk guard misses it because the caller's file need not sit under the canonical name (measured: anasset_Mixed_AO.pngdisplaced by a freshly writtenasset_Ambient_Occlusion.png). Rivalry itself is still judged against the winners alone, or an ORM beside a loose Metallic would read as a rival of whatever outranked it. +16 tests. -
2026-08-10 — A packed map with an unresolved source channel now follows a three-way
missing_map_rule, the same policy the Map Packer panel offers.force_packed_mapswas a boolean: either an ORM/MRAO/MSAO was skipped the moment one of its channels failed to resolve, or it was written no matter how little resolved — and the packer already had the useful middle setting the factory lacked.MapRegistrynow owns the vocabulary (MISSING_SKIP/MISSING_MULTI/MISSING_FORCE,resolve_missing_map_rule,allow_incomplete_pack) so both ends of the pipeline speak it, and the three handlers consult it at exactly the points the old boolean sat — skip and force behave as before,multiwrites the map once at least two channels resolved (enough that the result is a packed map rather than one map wearing a packed name).force_packed_maps=Truestill resolves toforce, so existing configs and scripts are unaffected. Two deliberate behavior changes, both toward what every rule already claimed. A set where nothing resolved is no longer written even underforce, matching the packer — that output was a texture conjured entirely out of constant fills. And the Mask Map now answers a lone source channel the way ORM/MRAO always have: it used to gate only the AO-only case, so a metallic-only or smoothness-only set wrote an MSAO under every rule including the strictest — a map whose absent smoothness fills white, i.e. every surface mirror-smooth, which is precisely whatskipexists to prevent. Above that floor each packing keeps its own channel guards (ORM still refuses a missing roughness/metallic at 2-of-3; MSAO's white AO fill is neutral, so metallic+smoothness still packs). +4 tests. -
2026-08-10 —
ImgUtils.rasterize_uv_trianglesreports coverage exactly, so full coverage can be thresholded on. It rounded each triangle's vertices onto the supersample grid and then tested at pixel CORNERS — two sub-texel biases (up to a whole sample from the rounding, half a sample from the corner test), both pulling toward -u/-v. Against a supersampled grid neither ever showed as a visibly wrong mask, which is why they survived; but they break the one question a coverage mask exists to answer exactly — does this texel lie ENTIRELY inside the geometry — and a consumer thresholding on full coverage then reads a partly-outside texel as fully inside. Vertices now stay in floating point and samples are taken at pixel centers. Geometry outside the image is cropped rather than clamped, too: clamping a vertex drags the edges meeting it across the image and paints a wedge along the border, so a triangle that merely overhangs (a UDIM layout, a projection with padding) smeared instead of being cut off. The exactness is what mayatk's lightmap bake now rests on — it uses full coverage to tell an island's own texels from the ring Arnold renders past its border — andRegionMaskPacker/rasterize_silhouetteinherit the same sharper edges. +2 tests; every other test in the suite is unchanged, which is the point: the biases were always sub-texel.And it stopped allocating four times the grid it reduces. The box-filter down to output resolution ran
supersampled.astype(np.float32).mean(...), whose cast is a transient 4x the size of the grid — measured 268 MB to reduce a single 2048-square map at supersample 4, inside whatever host process is baking and already holding the scene the bake came from. The block sum now accumulates into one output-sized integer buffer and rounds in integer arithmetic, so no float copy of either the grid or the result is materialized: peak scratch for a 4096 map drops from ~335 MB to ~134 MB. Byte-identical, and proved so rather than assumed — every sample is 0 or 255, so the only quotient that can land exactly on .5 is a half-covered texel, where round-half-up and the round-half-to-even it replaces both give 128; checked exhaustively across every possible coverage count for supersample 2/3/4/8/16. -
2026-08-10 — A packed source map (MSAO/ORM/MRAO) reaching
pack_orm_textureis decomposed instead of flattened; and the WebXR texture pass got 4.5x faster. Profiled the whole preview pipeline against a production push (231 MB FBX, 224 MB of embedded PNG, 11 materials): FBX2glTF 50-57s, sidecar+lightmaps 8.7s, texture optimize 31.8s, publish 0.03s. Two findings, one correctness and one cost.The correctness one. A material whose only mask is an MSAO map can be described to
pack_orm_texturein exactly one way — as one of its three named slots — and every way was wrong: the packed RGBA got flattened to luminance for that one channel while the other two took their fill values. Measured on the production room, themetallic_roughnesssidecar section (which is what repairs the ORM FBX2glTF loses) described three VDATS materials as{"metallic": <MSAO>}and packed them to roughness 0 and metallic 0.43 against a true 0.016 — mirror-metal, and worse than no repair at all, because it overwrote a roughly-correct converted ORM with a confidently wrong one. A packed map now supplies every channel it carries, with smoothness inverted to roughness on the way, and a loose map the caller named explicitly still wins for its own channel. Verified end-to-end through the real deliverer sequence: those three materials now read occlusion 0.61-0.88 / roughness 0.32-0.75 / metallic 0.003-0.02, and no material in the room comes out fully metallic or mirror-smooth.Handled is not the same as right, so it says so. A mask map from another engine family now unpacks and repacks correctly — and reports itself while doing it, because silence leaves the mismatch unfixed at the source while every push pays for a full-resolution channel split and an 8-bit round trip (MSAO carries smoothness, so roughness is reconstructed by inversion rather than read from an authored map), and any channel ORM has no slot for is simply dropped. The question is asked of the registry, not of hardcoded engine names: new
MapRegistry.shares_workflowcompares two map types' declared target workflows, so a writer emitting an ORM asks "does the map I was handed target the same engines ORM does?" without caring whether this run is glTF, UE or Godot. It returnsNone— notFalse— when either side declares no workflows, because an absent declaration is not an incompatible one (MRAOships an empty list) and a caller testing falsiness would warn about all of them.MapFactory.foreign_packingsis the single predicate all three consumers read —pack_orm_texture's per-map warning, the GLB writer's summary, and both DCCs' exporter check — so "wrong packing for this target" is defined once. It answers in two forms, because the two callers know different things: a writer knows what it is emitting (target=a map type, judged viashares_workflow), while an exporter knows which registry workflow the user chose as a texture template (workflow=, judged by declared membership — which is why the same MSAO map is foreign to a glTF template and native to an HDRP one). An unknown workflow name — a stale persisted UI value after a registry rename — warns and reports nothing, because a wrong name must never become "every mask map in the scene is foreign" and block an export. Only packed maps are eligible, and that restriction is the whole contract, not an optimisation: a packing belongs to an engine family, but a loose map'sworkflowsanswers a different question — which presets emit it. Caught by running the first general version against the production room's real sidecar, where it reported 6 offenders of which 3 were an ordinary AO map and two ordinary emissive maps:Ambient_Occlusiondeclares only the Standard preset, so it "shares no workflow with ORM" and the exporter gate would have fired on nearly every scene. Pinned by test.MeshConvert.sidecar_foreign_packingsanswers the same question about a built sidecar, before any conversion runs, so an exporter can gate pre-flight rather than discover it in the log afterwards. It lives besidebuild_scene_sidecar(the envelope's schema owner) because mayatk and blendertk cannot import each other, and it walks every string in every section so a section added later is covered without editing it. The per-material detail lines are joined by one highlighted headline (extra={"preset": "highlight"}, the established LoggingMixin convention) — in a DCC those detail lines arrive amid hundreds of others and the artist has no reason to be reading the log at all, so the actionable summary gets the formatting and a plain handler still prints the same text. On the production room: three detail lines, one headline.The mechanism is
MapFactory.unpack_to_channels— the generic front door to theunpack_*family, returning{canonical map type: image}for whatever a texture actually carries and{}for a loose map. It dispatches through the newPACKED_UNPACKERStable rather than deriving the split fromMapType.channels, because the layout a packed map ships in is not always the canonical one (MSAO and MRAO each have two in the wild, auto-detected per image from the alpha channel) — and it reports what the map carries (Smoothness), never what a caller wants (Roughness), so the return type stays honest for consumers that want smoothness. Spec/Gloss is deliberately absent: recovering PBR from it is a conversion, not a channel split.A latent corruption found while profiling, unrelated to either.
optimize_glb_texturesrecorded a single owning image perbufferView, but FBX2glTF genuinely points two images at one view (measured: 4 such pairs on the production GLB) — and co-owners can differ in the one thing that decides their encoding, because a lightmap is exempt from the resize and encodes lossless while its co-owner is not. The loser silently got the winner's bytes: with the lightmap winning, its co-owner kept full resolution; with the order reversed the lightmap got resized and lossy-encoded, which is exactly the corruption the structural exemption exists to prevent. Views now track every owner, the first supplies the shared bytes, and any co-owner whose final bytes differ gets its own appended view — carried as bytes rather than an index, because a co-owner may legitimately need the original payload (its own re-encode having been skipped). Pinned in both orderings, since the old bug was invisible in one of them. The production GLB is byte-identical either way: its shared pairs agree on their encoding, so they correctly stay shared and nothing is duplicated.The cost one.
optimize_glb_texturesdecoded, resized and re-encoded 239 MB of source PNG on one thread. The WebP encode alone is ~60% of that pass and Pillow releases the GIL through all three steps, so the per-image work now runs on a thread pool: 31.8s -> 7.1s, byte-for-byte identical output (pinned by test across worker counts — threads make the work order nondeterministic and the pass also dedupes identical payloads across images, so a result attributed to the wrong index would differ run to run with nothing failing). Threads rather than processes because the payloads are already in this process's memory.OPTIMIZE_WORKERScaps at 8 and is capped again by the core count: each worker holds a fully decoded source (a 4096 RGBA is 67 MB) and this routinely runs inside a DCC already holding the scene the export came from, so the ceiling is host memory, not cores. Aworkers=parameter overrides it;workers=1forces the serial path. -
2026-08-10 —
FileUtils.path_length_limit/exceeds_path_length: one answer to "is this path too long", shared by both DCCs. Over-long paths fail late and opaquely (a write that reports success but produced nothing, a texture the FBX plug-in silently cannot embed), and every consumer that wanted to check was about to hardcode 260. The limit is read, not assumed: Windows reportsMAX_PATH(260) unless the machine opted into long paths viaLongPathsEnabled, which raises it to the extended-length maximum of 32767; POSIX readsPATH_MAXthroughpathconfwith the near-universal 4096 as fallback.exceeds_path_lengthmeasures the absolute form — a shortsourceimages/x.pngresolving to a 300-character path is the case that actually breaks — and takes an optional stricter budget so a caller can leave headroom for a deeper destination tree. Backs mayatk's + blendertk's Scene Exportercheck_path_lengthand the Texture Path Editor's over-long-path warning. Five tests. -
2026-08-10 —
FileUtils.is_under: one definition of "is this path inside that directory". The predicate was hand-rolled ten times across pythontk/mayatk/blendertk/uitk, each with its own casing rule (somenormcase, some.lower(), some neither — case-sensitive on Windows, so a drive-letter difference read as "outside") and its own answer forpath == directory. Normalizes separators and platform casing, compares on a separator boundary (a barestartswithputs/proj2/x.pnginside/proj), and takesinclusive=for the identity case. Deliberately does noabspath: resolving a relative path here would measure it against the process CWD, which is almost never the base a caller means. New call sites use it; converting the nine pre-existing ones is logged in.claude/BACKLOG.md. Seven tests. -
2026-08-10 —
truncate(mode="path")takes aheadcap, so a caller can spend the budget on the end of the path. Path mode grew the head greedily with whatever the tail could not use, which meant raising the character budget widened the front of the path — the opposite of what a filename-identifies-the-texture column wants.head=1pins the front to the drive/root and hands the rest to the tail (the tail is grown first, so a lower cap can only widen it).head=Noneis the previous behavior, unchanged. -
2026-08-10 —
ImgUtils.inset_rects_to_texel_centers: a published atlas rect aims its content at border-texel CENTERS, not boundaries. A rect whose content edge lies ON a texel boundary makes every bilinear tap along a shared 3D edge split onto the texel beyond it — which in a packed atlas is gutter dilated from whatever unrelated object landed next door, so up to half the tap's weight lands on another object's lighting (snap_atlas_rectshad made that split exactly 50/50 — deterministic, not safe). Not the cause of the production room's visible panel seams, and measured rather than assumed: a post-fix bake publishes every wall rect on a texel center (verified in the delivered GLB) and the seam profile is unchanged (mean dip 0.960 vs 0.970 before), because that artifact is already present in the raw per-object bake — see mayatk's entry. A real sampling defect fixed on its own merits. The standard lightmap convention fixes it: per axis, the content span[edge0_px, edge1_px]re-maps to[floor+0.5, ceil−0.5], so an edge tap reads the border texel pure and neighbor gutters only matter to minified mips (which dilation already covers). Takes an optional per-rect content bbox (the island's UV bounds, for crop-composed rects whose content edges are not the rect's 0/1 —Nonemeans full span), tolerates float noise on exact boundaries, and passes sub-two-texel content through unchanged. Both DCC packers publish through it (mayatk with island bboxes, blendertk full-span); five tests pin the contract, including the production crop-fold rect verbatim. Four fixes out of the lightmap-artifact audit, each measured on the production room's delivered GLB.apply_glb_lightmapsis namespace-tolerant: exact node-name match first, then the leaf with the namespace stripped from BOTH sides, binding only when that leaf is unambiguous — because a manifest and an export can disagree aboutNS:without either being wrong (an older publisher stripped them; some exporters flatten them), and exact-only matching silently unbound every referenced object: the delivered room's racks all rendered black while their finished atlases sat on disk. Ambiguous leaves warn and skip (a guessed bind puts one object's lighting on another); five tests pin both directions, the ambiguity refusal and exact-beats-fallback. Lightmap textures sample CLAMP_TO_EDGE: atlas rects legally extend past [0,1] (the island-crop fold), and the shared REPEAT sampler turned any tap past an atlas edge into the opposite edge's texels — 13 of the room's 48 rects were exposed._embed_image_bytes(clamp=True)gives the lightmap its own clamp sampler without touching sampler 0.optimize_glb_texturesre-encodes lightmaps LOSSLESS: the exemption covered the resize but not the encode, so every lightmap still went through lossy WebP — YUV 4:2:0, chroma at half resolution and quantized, which on near-black texels is the classic magenta/green blotching and smears color across rect borders. Exempt images now write VP8L (still smaller than the source PNG); and the exemption itself became structural — any image bound as a texCoord-1 occlusion/emissive map is a lightmap whatever its name, closing the hole where the digest dedupe hands a lightmap payload another image's name and the name-only check resizes it anyway. Two new ImgUtils primitives, shared by both DCC bakers:fill_empty_texels(nearest-content fill of every background texel via one distance-transform pass, numpy-flood fallback without cv2 — background is what GPU mip chains average into island edges as dark halos at distance) andsnap_atlas_rects(re-derive each float rect from its integer pixel rect, so a publishedscaleOffsetsamples exactly the texels the assembler wrote — the un-snapped float missed by up to half a texel on every rect edge, a thin dark border on each shared instance edge). The WebXR viewer also setsanisotropy(min(8, hardware)) on lightmap textures — floors at grazing angles are precisely where plain trilinear collapses to the coarsest, gutter-averaged mips.