-
2026-08-20 —
MapFactory.detect_normal_map_formatre-reads at native resolution instead of abstaining on fine detail (core_utils/engines/textures/map_factory/_map_factory.py). The detector thumbnails to 512 before correlating -- but that reduction is a LOW-PASS over exactly the gradients the integrability statistic reads, so on maps whose relief is fine and shallow it averaged the evidence flat. Measured on two real OpenGL bakes: r fell from -0.368 to -0.105 (a 2048 production normal map, mean |XY| deviation 3.03 against a comparison bake's 14.78) and from -0.19 to -0.09 (the bundled 4096 test asset) -- a correct, confident answer downgraded toNoneby an optimization. The repo already knew:test_normal_map_orientation_conventioncarried the workaround in a comment, probing that asset atthreshold=0.05, which the parameter's own docstring calls noise level. The reduction stays as a fast path and is now just that -- when it does not answer (below the threshold, or under the gradient-std floor) the correlation is recomputed at native resolution before giving up. Cost is bounded and only the indeterminate minority pays it: ~68 ms on a 2048 map against the ~50 ms already spent decoding it. Both passes were also made leaner while the code was open, since this path exists precisely for large maps: the reduction is aresize(..., reducing_gap=2.0)rather thancopy() + thumbnail()(thumbnail is in-place and the full-size image now has to survive the re-read, so that spelling cost a 48 MB full-size copy on a 4k map first) -- byte-identical output, same aspect rule, measurably faster -- and only the R and G planes are materialized, the blue one being a third of the allocation for nothing (2048 map: 218 -> 201 MB peak, correlation identical to 0e+00). The statistic itself is unchanged, extracted to_normal_handedness_correlationso the fast path and the re-read cannot drift. Re-audited on four real production OpenGL bakes: 4/4 correct (was 3/4), green-flipped copies invert in all four, and ORM / base colour / flat fill / random noise still abstain. The docstring's "real normal maps land at |r| ~ 0.64-0.95" is corrected to the measured 0.19-0.77 -- the claim that justified the threshold was drawn from too narrow a sample, and the honest summary is that the sign is trustworthy well before the magnitude is. The asset test moves 0.05 -> 0.15; its map still abstains at the 0.25 default, correctly.test_map_factory.py+1. -
2026-08-20 — the normal-map handedness tag is recognised in EITHER order; only the token-first spelling was enumerated (
core_utils/engines/textures/map_registry.py).Normal_OpenGL/Normal_DirectXcomposed their aliases as<token><sep><tag>only, soNormalDXclassified andDXNormaldid not — it fell through to the untaggedNormaltype andUvTransfer.normal_conventionread it as OpenGL, inverting the green channel of a DirectX map with nothing said. The hand-written alias list is what marks this a gap rather than a rule:DXNwas in it, so tag-first spellings were already known to occur, and only the abbreviation got patched —rock_DXNclassified whilerock_DXNormal,rock_DX_Normal,rock_dx_nrmandrock_DirectX_Normaldid not. It cost a second, quieter failure too, the exact onecompose_aliases' docstring says the enumeration exists to prevent: an unregistered compound leaves its first token welded to the base name, sorock_DX_Normalstripped to texture setrock_DXand the normal map landed in a different set than the rest of its bake. Both types now compose both orders. The rule is adjacency, not position — which sharpens rather than reverses the 2026-08-19 narrowing: a tag TOUCHING the token is part of the suffix and counts, sorock_directx_normalreads as DirectX again (that entry listed it as narrowed to OpenGL; adjacent is a compound suffix, and reading an explicit tag as its opposite is not the cheap error the untagged default is), while a tag loose in the name is still not a declaration —DirectX_rock_Normal,rock_directx_final_normaland adx_project/directory in the path all stay untagged. Verified across 34 real exporter spellings plus a full non-normal sweep (Base_Color, ORM, Height, and the object-space / bent / world normals that must never enter the tangent slot): no reclassification outside the intended set.test_uv_transfer.py+2 (42); the sixtest_map_registry_*suites pass unchanged (111). -
2026-08-20 — every map
MeshConvertembeds now ships in the BIN chunk instead of as base64 in the JSON (file_utils/mesh_convert/_mesh_convert.py). The channel writers embed asdata:URIs, which keeps each edit inside the JSON chunk — no buffer offsets to recompute, the part of GLB surgery that silently corrupts a file — at base64's ~33% premium, priced for a local preview. Butcreate_glbships those same writers as a deliverable: measured onTURRETS_WIRES.glb, the sidecar's packed ORM put 4.0 MB of base64 in the JSON chunk — 45% of an 8.9 MB file, 1.0 MB of it pure overhead, all of it parsed before a loader can draw, and sitting oddly beside FBX2glTF's own maps in the BIN.GlbEditnow records what a session embedded and_relocate_embedded_imagesmoves it into the BIN once, on the owner's close, after every composed writer has had its say — so the mid-session no-offsets property is unchanged and the BIN is rebuilt a single time no matter how many channels wrote. Safe because it only ever appends: the existing BIN is copied verbatim and payloads land past its end, so every priorbufferViewkeeps its index, itsbyteOffsetand its bytes, and no accessor is touched (that is the difference fromoptimize_glb_textures, which rewrites in place and must recompute them). Only images this session embedded move — adata:URI the file arrived with is the caller's, an externaluriis unreadable from here, and rewriting either would be a side effect on input. Entries are tracked by identity, not index, so aprune_glb_texturesin the same session cannot shift them onto the wrong image. Two shapes decline the move rather than risk the file: a first buffer that declares auri(EXTERNAL — there is no BIN to append to), and a GLB with no BIN chunk but bytes after the JSON (an extension chunk the spec says to ignore, not discard —replace_restswaps the whole tail, and this class never read those bytes to put them back). Both keep the base64: a size cost is not a lost byte. The embedded texture also carries a name now (its image's stem): FBX2glTF names the textures it writes, so an unnamed one mid-list was a tell that a later pass added it. Repro'd file: JSON chunk 61.5% → 14.0%, no base64.test_mesh_convert.py+4,test_preview_server.py3 retargeted (158 + suite green). -
2026-08-20 —
UvTransferreports what a consolidation costs each source instead of leaving the loss silent (geo_utils/uv_transfer.py)._auto_sizereturns the largest source map, which is right for a re-bake in place and keeps meaning what it looks like only while each source holds the share of UV space it had. Consolidating several texture sets into one layout is where it stops: each set now owns a fraction of a map it used to own outright, so its texel density falls by the ratio of those shares while the file keeps the reassuring old resolution. Measured on a delivered asset (TURRETS_WIRES.glb, two 2048 sets into one 2048 layout): the turrets took 57.5% of the target and lost little; the wires took 9.4% having owned ~94% of their own map, so ~1988px of content was resampled into ~628px — a 3.17x linear loss that shipped as visibly flattened roughness (61% of wire texels collapsed onto a single 0.20 plateau, ~40% of the source's local detail surviving) with nothing in the log to say so. The size is unchanged — 2048 is an ample map for a small asset, the caller already hassizefor anything else, and inflating one on the tool's own initiative would quadruple every consolidation's cost uninvited. What changes is that the squeeze is now computed per source (sqrt(uv_area_at_source / uv_area_at_target), reported above_SQUEEZE_REPORT_THRESHOLD= 1.05) and named: "'wires' 3.16x (~81px of its 256px) … raisesizeor give it more of the target UV layout". Resolution cannot buy back a layout that gave a set too little room anyway — the fix for a bad squeeze is usually the packing, and the log now carries the numbers that choice needs. The label comes from the same map that measured the size, so a report cannot name a path that was never read. Geometry is optional: without it there is nothing to compare and the size is the plain floor.test_uv_transfer.py+5 (40). -
2026-08-20 —
LoggingMixin.use_logger(logger): an instance can adopt its host's logger (core_utils/logging_mixin.py).self.loggeris class-shared, which is right for a class's own diagnostics but wrong for a reusable component embedded in a host tool: a uitkPresetManagerliving inside a panel warned about THAT panel's presets on its own channel, invisible to the panel's log sink (setup_logging_redirectwires the host's logger only) — the user-facing schema-drift warning landed in the console alone. Adoption is instance-scoped (the override shadows the non-dataClassPropertythrough normal attribute lookup, so every other instance keeps the class logger;Nonereverts) and routes everyself.logger.*call through the host's handlers AND level — so the host's log-level dial governs the component's verbosity too. Class-level entry points (cls.set_log_level,cls.set_log_file) still address the class logger, documented. First consumers: both Scene Exporters hand their panel logger to the window's preset manager.test_logging_mixin.py+1 (76). -
2026-08-20 —
MeshConvert.optimize_glb_textures: a KTX2 GLB is no longer a terminal delivery artifact — each compressed texture embeds a core-readable fallback by default (file_utils/mesh_convert/_mesh_convert.py). KTX2 mode dropped the plainsourceoutright and declaredKHR_texture_basisuinextensionsRequired, so the deliverable could not re-import into Blender, Unreal or stock Unity — a caveat the exporters could only tooltip around. The spec defines the escape hatch, and the pass now uses it: every converted image also embeds a resized PNG/JPEG twin bound as the texture's plainsource(JPEG at quality for ETC1S color, PNG for UASTC normals/data and anything with alpha — lossy chroma would corrupt the very channels UASTC was chosen to protect), appended after the existing images so no index moves, and the extension stays inextensionsUsed.extensionsRequiredis now earned, not assumed: it appears only when some binding has no core-readable fallback — the newktx2_fallback=Falsepure-delivery mode (what the WebXR preview push passes: its GLB is streamed to a KTX2Loader-wired page, never re-imported, and the fallback bytes are the very bytes that pass exists to reclaim), or a fallback encode that failed (logged; that image ships KTX2-only rather than a stalesourcehanding a non-basisu loader KTX2 bytes labeled as readable).test_mesh_convert.py: the required-binding test became the fallback-binding contract, + pure-delivery and alpha-fallback tests (152 passed).