github m3trik/pythontk v0.9.33
pythontk v0.9.33

one hour ago
  • 2026-08-26 — renaming by type ate the name of anything whose name IS its own affix: a camera called Cam came back as a bare _CAM (str_utils/_str_utils.py, core_utils/naming_convention.py). apply_affix strips a pre-existing copy of the affix so it is idempotent, and nothing stopped that strip from consuming the WHOLE name: Cam matched _CAM, stripped to nothing, and the applied result was the affix alone with the object's name gone. A literal GEO under _GEO did the same. The strip now refuses to empty the core and falls back to the name as given. The guard is on EMPTINESS, not on case — which matters, because case-folding is the thing that usefully normalises a sloppy lowercase affix: body_geo still resolves to body_GEO instead of doubling to body_geo_GEO. (A case-sensitive strip was tried first and rejected: it fixed Cam but broke that normalisation AND still ate a literal GEO, since that one matches in exact case.) PRE-EXISTING, not a regression from the day's earlier affix work: measured at HEAD, the old strip plus the old exclude={rule.text} produced _CAM too. Caught by blendertk's test_naming.py, the only suite whose fixture happened to name a camera Cam — green again (48/48, and 4396/4396 for the full blendertk run). Four regression tests pin the name-is-the-affix case on both sides, the lowercase normalisation the guard must not cost, idempotency, and the convention path.

  • 2026-08-26 — the preview push reports its lightmaps: how many objects bound, and which came back unlit (net_utils/preview_server.py). A bake whose maps were not found renders exactly like no bake at all, and the applier's one warning names a file, not an outcome — so a migrated scene previewed unlit with "Preview live" on screen and nothing to say why. PreviewDeliverer._pass_lightmaps now reads the manifest from the same open session before binding and records {"expected", "bound", "unbound": [names]} under result["lightmaps"] (None when the pass never ran), and PreviewBridge.lightmap_summary(result) renders it as the sibling of sidecar_summary: empty for a scene with no committed bake, Lightmaps: 46/46 object(s) bound. when all bound, and otherwise the count and names of the objects that preview UNLIT. Tentacle's WebXR message box shows the line. Tests: LightmapSummaryTestCase, LightmapReportTestCase.

  • 2026-08-26 — the FBX half of a deliverable pair could not be read without a covering note; it now carries the same standalone-reader contract the GLB does (file_utils/mesh_convert/_mesh_convert.py). HANDOFF_INSTRUCTIONS has always ridden every GLB's extras.scene_sidecar.handoff, so a .glb handed to a dev, a foreign viewer or an agent explains its own structure. The FBX beside it explained nothing: 0 occurrences of handoff/instructions, only lightmap_metadata and 47 lightmapInfo markers, which state values and define none of them. New FBX_HANDOFF_INSTRUCTIONS + build_fbx_handoff(channels, source=None) are the twin, kept in the same class deliberately — the two carriers describe one pipeline, and a text written in two places drifts in two (which is exactly how the Blender copy of the texture-pass wording trailed Maya's). The load-bearing sentence is that the lightmaps are not embedded: an FBX carries what its MATERIALS reference and a lighting-only bake leaves the map unwired so the authored material survives, so the manifest's file names are join tokens against maps supplied separately — measured on a delivered room, 23.0 MB of the 23.5 MB file is embedded material textures and none of it the bake. Design points: the block carries no asset key (it is stamped by an export preparer, before any FBX path is chosen — the session hook fires for File ▸ Export too — so the only available name would be the SCENE's, naming a .ma as the deliverable; the scene rides under source as provenance instead); reads is built from the channels actually on the carrier, so a channel the scene never wrote is never claimed and one a newer producer wrote is described generically rather than dropped; and it publishes the same RENDERING_POLICY by copy, because a recipient who lights a baked asset normally blows out every baked surface whichever container it arrived in. FBX_HANDOFF_VERSION is separate from SIDECAR_VERSION — tying them would force a bump on one carrier whenever the other changed. Companion strip_fbx_handoff removes the block from a converted glTF's node extras: FBX2glTF transcribes every data_export user property into nodes[N].extras.fromFBX.userProperties (probe-measured on a real conversion, which is how this was found rather than assumed), so without it every GLB carried the FBX's account of itself -- stating that the lightmaps are not embedded, which is true of the FBX and FALSE of a GLB that embeds them, against reads paths a glTF does not have. A deliverable holding two accounts of itself, one wrong, is worse than one holding none, so it is dropped even for a bare conversion with no sidecar; lightmap_metadata and the per-object markers are left alone, being the applier's own input and read by consumers outside this repo. test_mesh_convert +7.

  • 2026-08-26 — every WebP texture shipped EXT_texture_webp with itself as its own fallback, so a GLB claimed to open in any reader and did not (file_utils/mesh_convert/_mesh_convert.py). The WebP binding set texture["source"] to the image it had just replaced with WebP bytes and the comment called that the spec's fallback. It is not one — it is the same image — so EXT_texture_webp sat in extensionsUsed (never extensionsRequired) while a core reader, which glTF 2.0 permits only image/jpeg and image/png, resolved the texture to WebP. Found on the delivered OFFICE_ENV.glb: both lightmap textures shipped that way with extensionsRequired unset, and test_lightmap_stays_lossless_webp pinned the bug — it asserted textures[1]["source"] == 1 alongside EXT_texture_webp.source == 1 and called it "WITH fallback". The WebP branch now applies the same rule the KTX2 branch already did: bind the fallback image when one exists, else drop source and escalate the extension to extensionsRequired. Consequently a plain WebP pass — where nothing core-readable can survive, that being the point of the format — now REQUIRES the extension instead of lying about it. Separately, KTX2 mode's exempt lightmaps no longer take WebP when ktx2_fallback=True: that flag's whole promise is "opens in a stock importer", which WebP breaks unless a PNG twin rides along, and WebP + twin costs MORE than the PNG alone (measured on the delivered room: 103 KB + 209 KB vs 209 KB). So fallbacks-on keeps them on the core-readable PNG and declares no extension for them at all; pure delivery keeps lossless WebP and requires it. Verified end-to-end by re-running the real pipeline over the production FBX: every texture resolves to a core-readable source, extensionsRequired unset. test_mesh_convert 167 green (the pinning test replaced by the two modes it should always have distinguished, plus an escalation check on the plain-WebP path).

  • 2026-08-26 — the KTX-Software installer ran with administrator rights having been checked against nothing (img_utils/ktx2_encoder.py). Its Windows entry is typed nsis — the one AppInstaller type that RUNS the download instead of unpacking it, and re-runs it through ShellExecuteExW('runas') on WinError 740 — and resolve_toktx passed no sha256=, so ensure skipped _verify_hash entirely. Consent is obtained for KTX-Software; what executed as Administrator was never confirmed to BE KTX-Software. New KTX_SOFTWARE_SHA256 pins all four v4.4.2 assets, mirroring the URLs' own _ARM64 conditional — ensure keys sha256 by PLATFORM while the URLs are arch-dependent, so a flat one-digest-per-platform table would hard-fail every arm64 install. Digests taken from the release's published per-asset hashes, with the Windows x64 entry re-verified by downloading and hashing it (6,417,024 bytes, sha256 1f323b0f...). Two tests guard it: every platform entry must carry a 64-hex digest, and resolve_toktx must actually hand the table to ensure — a digest table nothing passes verifies nothing. Pinning also rejects a truncated transfer for free.

  • 2026-08-26 — AppInstaller._download could not tell a truncated transfer from a finished one (core_utils/app_installer.py). The read loop treated an empty read as EOF and never reconciled the bytes written against the Content-Length it had already captured. http.client.HTTPResponse.read(amt) deliberately does NOT raise IncompleteRead when the peer closes gracefully mid-body, so a half-received file returned as success: reproduced with a local server declaring 200000 bytes and writing 80000 — _download returned normally and left the partial file in place, because ensure's cleanup only fires on an exception. For the nsis type that put a truncated binary behind a UAC prompt (the PE headers and requireAdministrator manifest sit at the FRONT of an NSIS self-extractor, so WinError 740 and the elevated re-run still happen). Now raises RuntimeError naming both counts, gated on a declared length so a chunked response is unaffected. Also reaches FFMPEG_PLATFORMS and FBX2GLTF_PLATFORMS, where the symptom was only a confusing BadZipFile.

  • 2026-08-26 — a corrupt archive escaped every resolve_* handler and surfaced to the artist as a raw tar error (core_utils/app_installer.py). ensure's docstring promises RuntimeError on a failed extraction, and each consumer catches (RuntimeError, OSError, LookupError) on that promise — but tarfile.ReadError, zipfile.BadZipFile and EOFError derive from none of the three. A truncated .tar.bz2 (or an HTML error page saved under the archive's name) sailed past Ktx2Encoder.resolve_toktx, past ImgUtils.resolve_ktx2_encoder and out of mayatk's scene exporter, whose guard is except FileNotFoundError — so instead of the documented "abort with the install URL", the export died with not a bzip2 file and no hint that a manual install was the fix. The extract call is now wrapped to honour the documented contract, with the real error chained as __cause__. Done once in ensure rather than by widening three consumer tuples — and not with a bare except, which would fold a genuine programming error into a misleading "install failed". Fixes ktx2_encoder, _mesh_convert and _uv_unwrap together.

  • 2026-08-26 — A GLB shipped unlit whenever its project moved, because nothing could tell the lightmap applier where the maps are NOW (file_utils/mesh_convert/_mesh_convert.py, net_utils/preview_server.py). apply_glb_lightmaps resolves the manifest's EXRs by basename against the authoring directory recorded when the bake was COMMITTED, and has always accepted a search_dirs override — but neither caller could pass one: fbx_to_glb had no such parameter, and PreviewDeliverer._pass_lightmaps called the applier bare. Measured on a delivered room whose maps had moved from production/maya/sourceimages to production/sourceimages: the manifest listed 48 objects, zero bound, the GLB shipped with no lightmap at all, and 47 copies of the dead absolute authoring path rode along in the deliverable (the hint scrub is gated on a successful embed). fbx_to_glb gains lightmap_dirs; PreviewBridge.lightmap_search_dirs() is the host hook the deliverer reads through PreviewPassContext.lightmap_search_dirs (via getattr, since a deliverer is pluggable onto a plain HandoffBridge and there the attribute error would be swallowed by the per-pass guard — the exact silent-unlit outcome the hook prevents). The same run proved the failure was also illegible: one warning per manifest ENTRY meant one moved folder printed 48 identical lines, so misses are now COUNTED and reported after the walk -- one line per missing MAP carrying the number of objects at stake (the thing that says how bad it is, and not knowable until the walk ends), plus a summary naming the total — "never baked" and "baked and none of it reached the file" are the same empty list and wildly different deliverables. Re-measured after: 46 of 48 bound (the two misses are props outside the exported root), authoring paths scrubbed to zero. test_mesh_convert +3.

  • 2026-08-26 — optimize_glb_textures reported which MODE ran, not what it did, so an untouched asset read as rescaled (file_utils/mesh_convert/_mesh_convert.py). max_size is a ceiling — a source already within it keeps its pixels, and the code has never upscaled (the clamp is guarded on max(size) > max_size; only the KTX2 power-of-two snap can move an edge, and it snaps DOWN). But the summary carried no way to say so, so the exporters labelled every run "(resized)" and a delivery of four 2048 maps under a 2048 ceiling announced itself as resized — read, reasonably, as "the exporter upscaled my lightmaps to 2K". The summary now carries resized, the count actually resampled, and new MeshConvert.describe_texture_pass(summary, image_format, max_size) renders the whole sentence -- twin of MapOptimizer.describe_size_clamp, and there for the same reason: the wording is a claim about this method's contract that BOTH DCC exporters have to make, and they cannot import each other, so a sentence written in both drifts in both (the Blender copy was already trailing Maya's). test_mesh_convert +2 (a 32/64/128 set under a 64 ceiling: resized == 1 and the 32 comes back 32; and the four outcomes the sentence has to distinguish).

  • 2026-08-26 — StrUtils.strip_any_affix folded case, so a whole convention table ate the ordinary words a name is made of (str_utils/_str_utils.py). The table-driven strip runs every entry of a ~19-token vocabulary at one name, from either end. Inherited case-insensitivity from strip_known_affix meant each uppercase marker matched any word that happened to spell it: with the shipped table, security_cam came back security, tile_set tile, con_rod rod, bolt_bs bolt — measured against the live NamingConvention.bind(SUFFIX_BINDINGS) table, no config needed. strip_any_affix now defaults to case_sensitive=True, which is the right default for a table: a type vocabulary is fixed-case by construction while the words around it are not. strip_known_affix gains the same keyword defaulting to False, so its released single-affix behaviour (Mat_brick for prefix MAT_) is untouched. The boundary rule was never the guard here — it only stops mid-token matches like Matte_door, not a real _-delimited English word.

  • 2026-08-26 — StrUtils.delimit_affix let a token's spelling outrank the mode the caller named, storing an affix that contradicts itself (str_utils/_str_utils.py). It returned any already-delimited token unchanged, on the reasoning that the delimiter says which side it goes. That holds for auto, and is wrong the moment a caller states a side: a convention row pre-filled with the suffix spelling _GEO whose picker is flipped to Prefix yielded the pair ('_GEO', 'prefix'), which applies as _GEObody — no separator, affix on the wrong side of it. An explicit prefix/suffix now re-sides the delimiter (_GEO + prefix → GEO_, GEO_ + suffix → _MAT-style _GEO); auto still treats a carried delimiter as the declaration. Fixed in the primitive rather than at the three call sites that had it (mayatk and blendertk naming panels, the extapps packer), which also spares them from re-implementing the delimiter rule past the delimiter parameter.

  • 2026-08-26 — NamingConvention read three layers deep and wrote one layer up, so a studio deployment silently discarded every panel edit (core_utils/naming_convention.py). The reader went through UserConfig.resolve, which prefers $PYTHONTK_NAMING_CONVENTION — the studio/project share the module docstring advertises — while every write went unconditionally to the per-user doc. With the env var set: an artist's change to Mesh applied for the rest of the session and was gone at the next launch; reset() was a no-op for the reader; and reset() additionally loaded the SHARE's entries as if they were the user's, popped one, and wrote the rest over the personal doc — destroying unrelated personal overrides. resolve() now merges three layers (DEFAULTS < studio doc < per-user doc), _load_overrides/_save_overrides/reset all name the one file the user owns, and _write diffs against _baseline() (defaults with the share overlaid) so a value equal to the share's is not persisted as a bogus personal override. A share stays read-only by construction: making the panel write to it would have let one artist rewrite the convention for everyone, which is worse than the bug. Five regression tests in test/test_naming_convention.py; four of them fail against the previous layering.

  • 2026-08-25 — MapFactory.extract_channels is public (was _extract_channels_from_packed) (core_utils/engines/textures/map_factory/_map_factory.py). "Turn a packed map's channels into loose maps beside it, reusing any already on disk" is not an internal detail of prepare_maps' rivalry pass — it is what any consumer that cannot sample a channel needs, and mayatk's game shader now needs exactly it (a StingrayPBS TEX_* slot binds only through its compound plug, so one image drives one slot). Same behaviour, same all-or-nothing contract, same "a target already on disk under its canonical name is REUSED, never overwritten"; only the name and the docstring changed. Both internal call sites moved with it.

  • 2026-08-25 — ImgUtils.DELIVERY_ONLY_FORMATS: the containers only a delivery carrier may hold (img_utils/_img_utils.py). DELIVERY_FORMATS already named the encode-only container (KTX2) that save_image routes to an external encoder, and mayatk/blendertk's scene-exporter clamp borrowed it to answer a different question — "may this container be written for a DCC scene or an FBX?". WebP answers no to that and yes to everything DELIVERY_FORMATS means, so it could not join it: it is PIL-readable, glTF embeds it natively (EXT_texture_webp), and folding it in would have routed every webp save to the KTX2 encoder. The new tuple is the superset the clamp actually wanted (("ktx2", "webp")), with the measurement in its comment: a Maya file node pointed at a 64x64 .webp reports outSize 0x0 where png / tga / jpg all report 64x64.

  • 2026-08-25 — A missing toktx can now be installed on demand: AppInstaller gains the nsis installer type and the shared consent seam (core_utils/app_installer.py, img_utils/ktx2_encoder.py, img_utils/_img_utils.py). Ktx2Encoder.resolve_toktx and ImgUtils.resolve_ktx2_encoder take auto_install / prompt like MeshConvert.resolve_binary, fetching the pinned release (KTX_SOFTWARE_VERSION 4.4.2, KTX_SOFTWARE_PLATFORMS) into the managed tools dir and binding the encoder to it. KTX-Software ships Windows only as an NSIS installer that requests administrator rights, so AppInstaller learned to stand a silent installer run in for extraction: /S /D=<tool dir> as ONE unquoted command line (NSIS reads everything after /D= as the path and rejects quotes), and WinError 740 re-runs it through ShellExecuteExW's runas verb and waits — the standard UAC prompt; the install still lands in the tool dir. Linux uses the plain tarball (bin/toktx finds lib/ by $ORIGIN rpath); macOS stays manual (a root-installing .pkg). prompt now also accepts a callable (question) -> bool — the seam a panel plugs its dialog into — via the new AppInstaller.consent, which MeshConvert.resolve_binary and UvUnwrap.resolve_engine now share instead of carrying their own copies of the console [y/N] (their messages and prompt=False contract are unchanged). The "no toktx" error names the switch and is built in one place (Ktx2Encoder.not_installed_error, which ImgUtils raises too). The nsis type is opt-in only — an .exe URL is never guessed into a type that runs the download — and the download's \r progress line now prints to a terminal only (a DCC script editor rendered ~600 of them). test_app_installer +6, test_ktx2_encoder +3, test_img +1.

  • 2026-08-25 — StrUtils.retain_suffix added a second copy of a suffix the new name still carried, and ate the number off one that was already right (str_utils/_str_utils.py). "Already present" was tested with new_name.endswith(old_suffix), which is false for every append pattern — those keep the whole old name by definition, so Sphere_GEO under **_A became Sphere_GEO_A_GEO, and Master_GRP under *_v2 became Master_GRP_v2_GRP. The same test missed a numbered suffix, so a host's auto-numbered pCube_GEO1 had its trailing token stripped and re-appended bare as pCube_GEO, collapsing distinct names onto one. Presence is now a token test — the suffix counts as retained when it is one of the new name's own _ tokens, trailing digits ignored — and a name that still carries it comes back untouched. Retaining carries a lost suffix over; it never adds one. test_str +2.

  • 2026-08-25 — StrUtils.format_suffix: a literal strip entry was removed wherever it appeared in the name, not from the end (str_utils/_str_utils.py). The regex-vs-literal gate asked re.compile(pattern) whether a token was a pattern, and every plain string compiles — so for anything longer than one character the literal branch was unreachable and each entry became a global re.sub, against a docstring that says "strip from the end". Stripping a naming convention's _LOC therefore ate the middle of a name: mayatk's create_locator_at_object turned ITA_LOCKHANDLE into ITAKHANDLE_GEO under ITAKHANDLE_LOC under ITAKHANDLE_GRP. A token now reaches re only when it actually spells a pattern (carries a metacharacter); a plain token is stripped only while the name still ENDS with it, repeatedly, as documented. An empty entry in a strip list was also an infinite loop (s.endswith("") is always true) and is now skipped. No other caller is affected — both naming modules pass literal affixes to strip_any_affix, which was already boundary-aware, and the remaining sites pass a single "_", forward the caller's own strip (resolve_name_collisions, which has no consumer outside this package), or strip nothing. test_str +1; test_format_suffix_list_strip re-expressed, since its ["Cu", "be1"] case was asserting the mid-name removal itself.

  • 2026-08-25 — MeshConvert.set_glb_alpha_mode + the alpha_mode scene-sidecar section (file_utils/mesh_convert/_mesh_convert.py). FBX2glTF decides a material's alphaMode from the base colour's alpha channel alone: an RGBA base colour becomes BLEND, never MASK, and an RGB one OPAQUE — so the one material fact the DCC knows and the FBX cannot carry, "this is a cutout", was lost on the way to WebXR and a solid body wearing the material blended its own back faces through its front. The applier writes alphaMode / alphaCutoff by material name (cutoff only for MASK, a stale one dropped otherwise; unknown modes skipped with a warning, missing names reported like the other writers) and is registered in SIDECAR_APPLIERS, so a DCC-side reader — mayatk's SceneState._read_alpha_mode — reaches the GLB through the existing apply_scene_sidecar dispatch with no exporter change. test_mesh_convert +3.

  • 2026-08-25 — NamingConvention: one definition of "what affix marks a mesh / a material / a group", read by the whole toolset (core_utils/naming_convention.py). Every tool used to answer that question for itself: mayatk's and blendertk's Naming.SUFFIX_TYPES held two verbatim copies of a 19-row table, rig_utils hardcoded "_GRP"/"_LOC"/"_GEO" in its signatures, image_to_plane defaulted to "_MAT", and the Naming panel persisted the user's REAL convention onto nineteen QLineEdit objectNames where nothing else could read it — so renaming _GEO to _MSH meant editing every one of those places and finding the ones you missed at the wrong moment. An entry is an affix, not a suffix (AffixRule: a spelling plus the side it lands on, over the existing split_affix/apply_affix primitives), so a studio that writes GEO_body changes the convention rather than the tools. It lives here and not in the naming module because uitk's affix picker resolves against it and cannot import a DCC toolkit, and extapps has no DCC at all; what IS host-specific — resolving a live node to a type key — stays in mayatk/blendertk Naming.type_key. Storage follows UserConfig and writes only the entries that differ from the shipped defaults, so a default that moves in a later release still reaches everyone who never overrode it. 19 node-type entries plus 3 artifact ones (lightmap, packedTexture, control) that no host binds. test_naming_convention 24 tests.

  • 2026-08-25 — StrUtils.strip_any_affix, and a per-process nondeterminism in longest-first affix stripping (str_utils/_str_utils.py). The table-driven companion to strip_known_affix: a type-suffix pass knows the whole vocabulary but not which entry a given name wears — nor, once a convention can be spelled either way, which END it wears it on, so body_GEO and GEO_body both yield body and re-running after a convention flips sides corrects the name instead of doubling it. Extracted from the copy mayatk and blendertk each carried. The bug that copy carried with it: it ordered a set by length alone, and Python randomizes string hashing per process — so for two equal-length affixes (_GEO, _MAT) which one got stripped varied from session to session. Measured across 4 interpreters: 3 distinct orders. Tie-break is now alphabetical, giving a stable total order. A cheap necessary-condition pre-filter runs before the regex (the token's core must sit at one trimmed edge): a full type vocabulary is ~20 tokens of which at most one ever matches, and building two patterns for the other nineteen cost 233 ms per 5000 names — 42 ms with the filter, measured. test_affix_hardening +11 including a cross-process regression guard.

  • 2026-08-25 — NamingConvention.bind and StrUtils.delimit_affix, the two operations the toolset was about to duplicate. bind is the join every type-driven rename runs — {host type key: AffixRule} from a host's (keyword, convention key, host key) bindings, with per-call overrides — and it lives here because the only host-specific part is the bindings table itself; mayatk and blendertk had written it out verbatim, twice. delimit_affix is the forgiving counterpart to apply_affix: the affix carries its own separator (the applier concatenates verbatim and must not guess), so a field a user types "MAT" into delimits it to MAT_ or _MAT on the selected side first. Three call sites had grown their own copy of that rule — both naming panels and the texture packer. test_naming_convention +8, test_affix_hardening +7.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.