Skip to content

pythontk v0.9.14

Latest

Choose a tag to compare

@m3trik m3trik released this 11 Aug 18:05
  • 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 to aoMap. 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's CARRIERS, refusing anything absent from it: the map then stays in the slot it arrived in, where it still reads as plausible occlusion. A Map, 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 naming constructor as 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 .glb had 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_sidecar now embeds a copy of the envelope carrying textures{authoring path: {image, sha256, bytes, mimeType}} — plus validate (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, so metallic/roughness/occlusion each 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.json inspection 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_textures re-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 of extras and 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_sidecar adds handoff = 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, the textures map) plus reads and sections, 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's asset key so it reads correctly from the .scene.json copy too. SIDECAR_VERSION → 2.

    Also folded out a duplicated resolution while wiring this: GlbEdit.image_for_texture is now the one place that knows an EXT_texture_webp binding shadows a texture's plain source (the optimizer's lightmap-exemption scan had its own inline copy), bounds-checked like its base_color_image sibling. Every DCC consumer was checked and needed no change — they build the envelope and read sections, 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 as occlusionTexture (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_lightmaps in turn distinguishes that binding from authored data by texture-index equality with the material's own metallicRoughnessTexture: 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. New replace_authored=False keeps 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 normalTexture at texCoord 0 pointing at correctly-named OpenGL-convention maps, and every primitive ships TEXCOORD_0 + TEXCOORD_1. The problem was downstream of the wiring. three.js adds lightMap irradiance through BRDF_Lambert, which has no normal term — a bake supplies light that does not vary with the surface normal at all — so with scene.environment = null and 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 (and l) toggles bake + 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.envMapIntensity per 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 any MeshStandardMaterial whose envMap is nullif (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 own envMap, which disposeModel (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 across PreviewServer / 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, because max_size is 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] message line turned a 20-set batch into 20 blank-line-separated sections in the Compositor panel. The ticks accumulate and go out as one log_group in a finally, so a batch that dies partway still reports the sets it prepared. The rest of the file already used log_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 is os.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 subdirectory new with separators silently lands outside the project. is_rooted_path requires a drive or UNC share (Windows) or a leading / (POSIX), and resolve_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 eventual os.makedirs would 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 returns None and 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 listed ORM and MSAO on every material. Redundancy precedence is keyed off MapType.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_connection pinned the loser's file node so the foreign mask map rode into the FBX → GLB as dead payload — while the exporter's own foreign_packings gate 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's config_key) > requested only by force_packed_maps > declared breadth (ORM ships to UE/glTF/Godot, MSAO only to HDRP) > registration order. Separating the two request levels matters — force_packed_maps means "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_conflicts runs 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, so Albedo_Transparency (base colour + opacity) is never weighed against an ORM. Coverage needed no new machinery — the conversion registry already declares Roughness ← ORM and Smoothness ← 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_covered both 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 own asset_Mixed_AO.png wrote a redundant asset_Ambient_Occlusion.png and 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.replaces lists Metallic_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: an asset_Mixed_AO.png displaced by a freshly written asset_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_maps was 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. MapRegistry now 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, multi writes 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=True still resolves to force, 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 under force, 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 what skip exists 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_triangles reports 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 — and RegionMaskPacker / rasterize_silhouette inherit 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_texture is 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_texture in 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, the metallic_roughness sidecar 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_workflow compares 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 returns None — not False — when either side declares no workflows, because an absent declaration is not an incompatible one (MRAO ships an empty list) and a caller testing falsiness would warn about all of them.

    MapFactory.foreign_packings is 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 via shares_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's workflows answers 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_Occlusion declares 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_packings answers 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 beside build_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 the unpack_* family, returning {canonical map type: image} for whatever a texture actually carries and {} for a loose map. It dispatches through the new PACKED_UNPACKERS table rather than deriving the split from MapType.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_textures recorded a single owning image per bufferView, 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_textures decoded, 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_WORKERS caps 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. A workers= parameter overrides it; workers=1 forces 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 reports MAX_PATH (260) unless the machine opted into long paths via LongPathsEnabled, which raises it to the extended-length maximum of 32767; POSIX reads PATH_MAX through pathconf with the near-universal 4096 as fallback. exceeds_path_length measures the absolute form — a short sourceimages/x.png resolving 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 Exporter check_path_length and 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 (some normcase, some .lower(), some neither — case-sensitive on Windows, so a drive-letter difference read as "outside") and its own answer for path == directory. Normalizes separators and platform casing, compares on a separator boundary (a bare startswith puts /proj2/x.png inside /proj), and takes inclusive= for the identity case. Deliberately does no abspath: 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 a head cap, 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=1 pins 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=None is 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_rects had 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 — None means 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_lightmaps is 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 about NS: 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_textures re-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) and snap_atlas_rects (re-derive each float rect from its integer pixel rect, so a published scaleOffset samples 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 sets anisotropy (min(8, hardware)) on lightmap textures — floors at grazing angles are precisely where plain trilinear collapses to the coarsest, gutter-averaged mips.