Skip to content

pythontk v0.9.28

Latest

Choose a tag to compare

@m3trik m3trik released this 20 Aug 18:45
c5a481d
  • 2026-08-20 — MapFactory.detect_normal_map_format re-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 to None by an optimization. The repo already knew: test_normal_map_orientation_convention carried the workaround in a comment, probing that asset at threshold=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 a resize(..., reducing_gap=2.0) rather than copy() + 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_correlation so 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_DirectX composed their aliases as <token><sep><tag> only, so NormalDX classified and DXNormal did not — it fell through to the untagged Normal type and UvTransfer.normal_convention read 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: DXN was in it, so tag-first spellings were already known to occur, and only the abbreviation got patched — rock_DXN classified while rock_DXNormal, rock_DX_Normal, rock_dx_nrm and rock_DirectX_Normal did not. It cost a second, quieter failure too, the exact one compose_aliases' docstring says the enumeration exists to prevent: an unregistered compound leaves its first token welded to the base name, so rock_DX_Normal stripped to texture set rock_DX and 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, so rock_directx_normal reads 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_normal and a dx_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 six test_map_registry_* suites pass unchanged (111).

  • 2026-08-20 — every map MeshConvert embeds now ships in the BIN chunk instead of as base64 in the JSON (file_utils/mesh_convert/_mesh_convert.py). The channel writers embed as data: 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. But create_glb ships those same writers as a deliverable: measured on TURRETS_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. GlbEdit now records what a session embedded and _relocate_embedded_images moves 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 prior bufferView keeps its index, its byteOffset and its bytes, and no accessor is touched (that is the difference from optimize_glb_textures, which rewrites in place and must recompute them). Only images this session embedded move — a data: URI the file arrived with is the caller's, an external uri is unreadable from here, and rewriting either would be a side effect on input. Entries are tracked by identity, not index, so a prune_glb_textures in 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 a uri (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_rest swaps 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.py 3 retargeted (158 + suite green).

  • 2026-08-20 — UvTransfer reports what a consolidation costs each source instead of leaving the loss silent (geo_utils/uv_transfer.py). _auto_size returns 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 has size for 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) … raise size or 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.logger is class-shared, which is right for a class's own diagnostics but wrong for a reusable component embedded in a host tool: a uitk PresetManager living inside a panel warned about THAT panel's presets on its own channel, invisible to the panel's log sink (setup_logging_redirect wires 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-data ClassProperty through normal attribute lookup, so every other instance keeps the class logger; None reverts) and routes every self.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 plain source outright and declared KHR_texture_basisu in extensionsRequired, 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 plain source (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 in extensionsUsed. extensionsRequired is now earned, not assumed: it appears only when some binding has no core-readable fallback — the new ktx2_fallback=False pure-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 stale source handing 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).