github m3trik/pythontk v0.9.8
pythontk v0.9.8

5 hours ago
  • 2026-08-06 — FBX→GLB: the post-conversion repairs read the whole file three times and rewrote it twice to edit a few JSON fields. The conversion subprocess is the floor, but everything after it was paying by the size of the geometry. _read_glb slurped the entire file and _write_glb wrote all of it back, and the preview path runs three repairs in a row on the same GLB — fix_glb_phantom_opaque_alpha inside fbx_to_glb, then set_glb_base_color, then set_glb_emissive — each opening, parsing and rewriting for itself. Every one of them edits the JSON chunk and nothing else; the BIN chunk was just being carried along.

    MeshConvert.GlbEdit + open_glb collapse that to one read and one write. Each repair now takes either a path or an open session, so the standalone path form is unchanged while PreviewDeliverer._apply_sidecar wraps every section in one open_glb and the whole sidecar costs a single write. The session is also where the two duplicate alpha-probe closures merged into one cached alpha_extrema, so an atlas shared by twenty materials decodes once across repairs rather than once inside each, and where the texture-embed cache moved — a map assigned as both base colour and emissive used to be base64'd into the file twice.

    rest is lazy and bin_data is a view. Nothing in the alpha check needs the BIN chunk until a material already looks wrong, so a GLB with nothing to fix is never read past its JSON chunk — which is the case that runs after every conversion, including the scene exporter's, where the repair had been reading a hundreds-of-megabytes file to decide it had no work. bin_data was a bytes slice, i.e. a full copy of the geometry alongside the buffer it came from, putting peak memory at twice the file size; it is a memoryview now, and every consumer only ever read it.

    A JSON-only edit no longer rewrites the file. This module re-serializes compactly while most producers do not, so the new JSON usually still fits the chunk it came from; it is padded back to exactly that length and written in place at a fixed offset, leaving the total size and every byte after it untouched. Growing edits (a base64 texture embed) still take the full-rewrite path. Padding out rather than shrinking to fit is what keeps it safe — the chunk header, the total-length field and every following byte stay correct only if the chunk keeps its size.

    _embed_image now warns and skips an unreadable PNG/JPEG instead of raising. Found reviewing the above: the re-encode branch caught (OSError, ValueError) and returned None, but the direct read for a format glTF accepts natively did not — so a texture that exists yet cannot be read (permissions, a network path that dropped, deleted between the isfile check and the open) aborted the whole channel writer, while the same failure on a TGA warned and skipped. Extension alone decided which. It also mattered more after the change above: that read was the one file operation left inside an applier, and with the sections now sharing one session an exception escaping mid-mutation is the one way a partly-applied edit could reach disk. Its record already gated on the embed result, so the material's colour still writes and the record correctly reports no texture.

    Behaviour is otherwise unchanged for every caller (all pass paths positionally; glb_pathglb renames no keyword anyone used). Ten new tests pin the invariants that made the collapse safe: one write per session, two for two path-form calls, no write with nothing to match, in-place edits leaving file size and BIN bytes identical, the full-rewrite fallback returning the geometry whole, a clean GLB never touching rest, bin_data being a view, one embed across both channels, a raising body writing nothing, and an unreadable texture skipping rather than raising. Also folded onto the session: _extract_image_bytes (as image_bytes), the material→pbr→texture→image walk both alpha repairs open with (as base_color_image — written twice it had already drifted, one copy bounds-checking the image index and the other leaving it to the probe), and the name/uri/index fallback each used to label a finding (as image_label); the three copies of the empty-container cleanup became _prune_empty_containers. pythontk 2732 green; extapps 566 green.

  • 2026-08-06 — Map taxonomy: one normal convention resolved and its twin didn't, non-tangent normal bakes were wired as tangent normals, and a compound suffix split a texture set in two. Four defects in MapRegistry, found probing the classifier against real-world suffix conventions rather than reading the alias table.

    OGL was not an alias while DX was, so rock_NRML_DX.png resolved to Normal_DirectX and rock_NRML_OGL.png resolved to nothing — one half of a bake pair dropped as an unknown map. GL could not cover it: the short-alias boundary rule rejects an uppercase predecessor (the Agilent_E4419B guard), and the O in OGL is exactly that. The convention spellings now come from MapType.compose_aliases, a cross-product of normal tokens (Normal/NormalMap/Norm/NRML/NRM/NML/N) with convention tags (GL/OGL/OpenGL, DX/DirectX) over both separators, so neither side can be more complete than the other again.

    A compound <type>_<convention> suffix came off in pieces. get_suffix_strip_pattern strips ONE trailing alias, so rock_NRML_DX.png classified correctly but based to rock_NRML — while its sibling rock_DIFF.png based to rock, putting the two halves of one bake in different texture sets. Enumerating the compounds is deliberate, and the alternative is why: stripping in a loop until nothing matches eats material names, turning Gold_Metal_Diffuse into Gold (Metal is a registered alias of Metallic). A test pins that.

    Bakes that are not tangent-space normals classified as Normal. Every one of them ends in the token Normal and longest-first matching had no longer candidate to prefer, so mat_WorldNormal.png and mat_BentNormal.png were wired into the tangent-normal slot and rendered wrong with no warning. Normal_Object, Normal_World and Bent_Normal are now their own types, and deliberately absent from NORMAL_TYPESselect_normal_type must never offer one as the shader's normal map, since they need a different shading setup rather than a different handedness. Vector_Displacement joins them for a different reason: it classified as Displacement, whose declared mode is L, so a VDM's XYZ offsets were flattened to grayscale and two of three axes discarded. Utility bakes with no such hazard (Curvature, Cavity, Position) stay unregistered — unknown routes to passthrough, which is already correct, and Curvature in particular is the registry's documented custom-type example.

    Backlogged 2026-08-05, closed here: _short_alias_boundary accepted _ - . and space while the strip pattern's delimited branch was _-only, so rock-ao.png classified as Ambient_Occlusion but based to rock-ao, and rock-basecolor.png based to rock- (the boundary-free attached branch matched one character later, stranding the delimiter). All four readings of "does this filename end in a map-type suffix" now derive from one MapRegistry.SEPARATORS constant — the third, MapFactory.resolve_map_type(key=False), had its own hardcoded "_" and would have renamed a --delimited file on round-trip; the fourth is the joiner set inside a compound suffix, which review caught covering only _ and glued, leaving rock-nrml-ogl classifying as Normal_OpenGL while it based to rock-nrml — the same split surviving under a different delimiter. The trailing-delimiter collapse was _-only for the same reason (rock_.pngrock, but rock-.pngrock-).

    Two callers paired the DirectX and OpenGL alias tuples by INDEX, found reviewing the above — MapFactory.convert_normal_map_format (live) and MapCompositor._try_invert_normal. Walking index(typ) in one tuple and subscripting the other silently required both lists to stay the same length and in lockstep order. They never were: DXN sat past the end of the shorter tuple, raising IndexError in one caller and falling back in the other. Generating the convention spellings broke it outright — im_NDX.png converted to im_NRMGL.png, Norm_DX to NormalMap_OGL. Both now call MapRegistry.counterpart_normal_spelling, which swaps the trailing convention tag and keeps everything before it verbatim, so a converted map keeps its source file's naming style (rock_NormalDXrock_NormalGL) and a spelling carrying no tag falls back to the canonical name. The existing conversion tests used canonical names only — index 0 in both tuples, the one position where the old pairing was correct — so the alias spellings are now covered too.

    Structural: the two get_base_texture_name twins are one implementation. Both docstrings claimed the shared registry pattern kept them from drifting, and they had drifted anyway — only the MapFactory side dropped the UDIM/UV-tile token, so a tiled filename produced two different base names depending on the entry point, and _map_factory itself reaches the ImgUtils one when naming packed outputs. MapFactory.get_base_texture_name now delegates. Also added: NRML/NML/Normals (Normal), Col/Alb (Base_Color), Specularity (Specular), and a standing guard that no spelling is claimed by two map types. A bare Bent alias was withdrawn during review — aliases over 3 chars match with no boundary check, so it claimed mat_absorbent.png; that boundary-free long-alias rule is a wider latent issue and is backlogged, not silently widened here. Also dropped a dead RGH alias on Roughness (case-insensitively identical to its Rgh neighbour in every matching path). pythontk 2710 green; downstream mat suites green (extapps 566, mayatk 596, blendertk 354).

  • 2026-08-06 - The RPC pair now ships both ends, and HandoffBridge grew the return leg. Two structural gaps in the app-bridge stack, closed together. (1) net_utils/rpc/plugin_core.py - RpcPlugin / OpRegistry / MainThreadMarshaller: the server that runs inside a host application, beside the RpcClient that drives it from outside. It existed only as five near-copies scattered across mayatk, blendertk and extapps; diffing them showed the entire divergence was docstrings, a host module name, an env prefix, a port and a thread label - zero logic. All of that is now constructor data. Instance-owned registries replace the module-global _OPS (so two plugins can be hosted in one process, and tests build a throwaway registry instead of calling a clear() hook), and the core registers system.ping / system.list_ops / system.describe itself - those are part of the client contract (RpcClient.list_ops invokes an op, not a route), so a plugin that forgot one broke a documented client method, and they had already drifted apart. Standard-library only, because an installed plugin payload carries a verbatim copy where pythontk is not importable; m3trik/scripts/sync_rpc_core.py stages it and test_sync_rpc_core.py pins both the copies and the stdlib-only rule. (2) The inbound axis: the invariant flow is now resolve -> preflight -> produce -> deliver -> ingest, with ROUND_TRIP beside SEND_TO / SAVE_AS and ScriptRoundTripDeliverer as its strategy. Half the fleet already round-trips (RizomUV imports UVs back; the scene-import pair is a whole inbound pipeline) but send() was documented one-way, so every return leg was bespoke. _ingest is a plain hook, not a Strategy - every real inbound leg is irreducibly host-specific, so a strategy object would be indirection with one implementation each. The in-place contract differs from save_as in exactly two ways, both now enforced: no staging sibling (input and output are one path), and ScriptRunner judges the run by the artifact having changed (expect=REWRITTEN) rather than appearing - clearing it first would delete the app's own input, and an app that exits 0 without saving would otherwise hand the caller back its untouched export to re-ingest as though it had been processed. Also HandoffBridge.deliverers defaults to None rather than a shared mutable {} (backlogged 2026-08-05). Two hazards found in review of the above and fixed with it: a secondary spec (run_spec / round_trip_spec) that omits modes= inherits ScriptLaunchSpec's default (SEND_TO,) and would have silently REPLACED the interactive send deliverer -- send() would then run the target headlessly and fail on a missing artifact with nothing pointing at the one-word omission, so a mode claimed twice now raises at construction; and round_trip() warns when _ingest is not overridden, since the default identity ingest would run the target and discard its result -- a round trip that looks like it worked.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.