-
2026-08-26 — renaming by type ate the name of anything whose name IS its own affix: a camera called
Camcame back as a bare_CAM(str_utils/_str_utils.py,core_utils/naming_convention.py).apply_affixstrips a pre-existing copy of the affix so it is idempotent, and nothing stopped that strip from consuming the WHOLE name:Cammatched_CAM, stripped to nothing, and the applied result was the affix alone with the object's name gone. A literalGEOunder_GEOdid 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_geostill resolves tobody_GEOinstead of doubling tobody_geo_GEO. (A case-sensitive strip was tried first and rejected: it fixedCambut broke that normalisation AND still ate a literalGEO, 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 oldexclude={rule.text}produced_CAMtoo. Caught by blendertk'stest_naming.py, the only suite whose fixture happened to name a cameraCam— 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_lightmapsnow reads the manifest from the same open session before binding and records{"expected", "bound", "unbound": [names]}underresult["lightmaps"](Nonewhen the pass never ran), andPreviewBridge.lightmap_summary(result)renders it as the sibling ofsidecar_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_INSTRUCTIONShas always ridden every GLB'sextras.scene_sidecar.handoff, so a.glbhanded to a dev, a foreign viewer or an agent explains its own structure. The FBX beside it explained nothing: 0 occurrences ofhandoff/instructions, onlylightmap_metadataand 47lightmapInfomarkers, which state values and define none of them. NewFBX_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 noassetkey (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.maas the deliverable; the scene rides undersourceas provenance instead);readsis 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 sameRENDERING_POLICYby copy, because a recipient who lights a baked asset normally blows out every baked surface whichever container it arrived in.FBX_HANDOFF_VERSIONis separate fromSIDECAR_VERSION— tying them would force a bump on one carrier whenever the other changed. Companionstrip_fbx_handoffremoves the block from a converted glTF's node extras: FBX2glTF transcribes everydata_exportuser property intonodes[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, againstreadspaths 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_metadataand 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_webpwith 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 settexture["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 — soEXT_texture_webpsat inextensionsUsed(neverextensionsRequired) while a core reader, which glTF 2.0 permits onlyimage/jpegandimage/png, resolved the texture to WebP. Found on the delivered OFFICE_ENV.glb: both lightmap textures shipped that way withextensionsRequiredunset, andtest_lightmap_stays_lossless_webppinned the bug — it assertedtextures[1]["source"] == 1alongsideEXT_texture_webp.source == 1and 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 dropsourceand escalate the extension toextensionsRequired. 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 whenktx2_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,extensionsRequiredunset.test_mesh_convert167 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 typednsis— the oneAppInstallertype that RUNS the download instead of unpacking it, and re-runs it throughShellExecuteExW('runas')on WinError 740 — andresolve_toktxpassed nosha256=, soensureskipped_verify_hashentirely. Consent is obtained for KTX-Software; what executed as Administrator was never confirmed to BE KTX-Software. NewKTX_SOFTWARE_SHA256pins all four v4.4.2 assets, mirroring the URLs' own_ARM64conditional —ensurekeyssha256by 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, sha2561f323b0f...). Two tests guard it: every platform entry must carry a 64-hex digest, andresolve_toktxmust actually hand the table toensure— a digest table nothing passes verifies nothing. Pinning also rejects a truncated transfer for free. -
2026-08-26 —
AppInstaller._downloadcould 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 theContent-Lengthit had already captured.http.client.HTTPResponse.read(amt)deliberately does NOT raiseIncompleteReadwhen 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 —_downloadreturned normally and left the partial file in place, becauseensure's cleanup only fires on an exception. For thensistype 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 raisesRuntimeErrornaming both counts, gated on a declared length so a chunked response is unaffected. Also reachesFFMPEG_PLATFORMSandFBX2GLTF_PLATFORMS, where the symptom was only a confusingBadZipFile. -
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 promisesRuntimeErroron a failed extraction, and each consumer catches(RuntimeError, OSError, LookupError)on that promise — buttarfile.ReadError,zipfile.BadZipFileandEOFErrorderive from none of the three. A truncated.tar.bz2(or an HTML error page saved under the archive's name) sailed pastKtx2Encoder.resolve_toktx, pastImgUtils.resolve_ktx2_encoderand out of mayatk's scene exporter, whose guard isexcept FileNotFoundError— so instead of the documented "abort with the install URL", the export died withnot a bzip2 fileand 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 inensurerather than by widening three consumer tuples — and not with a bareexcept, which would fold a genuine programming error into a misleading "install failed". Fixesktx2_encoder,_mesh_convertand_uv_unwraptogether. -
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_lightmapsresolves the manifest's EXRs by basename against the authoring directory recorded when the bake was COMMITTED, and has always accepted asearch_dirsoverride — but neither caller could pass one:fbx_to_glbhad no such parameter, andPreviewDeliverer._pass_lightmapscalled the applier bare. Measured on a delivered room whose maps had moved fromproduction/maya/sourceimagestoproduction/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_glbgainslightmap_dirs;PreviewBridge.lightmap_search_dirs()is the host hook the deliverer reads throughPreviewPassContext.lightmap_search_dirs(viagetattr, since a deliverer is pluggable onto a plainHandoffBridgeand 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_texturesreported which MODE ran, not what it did, so an untouched asset read as rescaled (file_utils/mesh_convert/_mesh_convert.py).max_sizeis a ceiling — a source already within it keeps its pixels, and the code has never upscaled (the clamp is guarded onmax(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 carriesresized, the count actually resampled, and newMeshConvert.describe_texture_pass(summary, image_format, max_size)renders the whole sentence -- twin ofMapOptimizer.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 == 1and the 32 comes back 32; and the four outcomes the sentence has to distinguish). -
2026-08-26 —
StrUtils.strip_any_affixfolded 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 fromstrip_known_affixmeant each uppercase marker matched any word that happened to spell it: with the shipped table,security_camcame backsecurity,tile_settile,con_rodrod,bolt_bsbolt— measured against the liveNamingConvention.bind(SUFFIX_BINDINGS)table, no config needed.strip_any_affixnow defaults tocase_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_affixgains the same keyword defaulting toFalse, so its released single-affix behaviour (Mat_brickfor prefixMAT_) is untouched. The boundary rule was never the guard here — it only stops mid-token matches likeMatte_door, not a real_-delimited English word. -
2026-08-26 —
StrUtils.delimit_affixlet 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 forauto, and is wrong the moment a caller states a side: a convention row pre-filled with the suffix spelling_GEOwhose picker is flipped to Prefix yielded the pair('_GEO', 'prefix'), which applies as_GEObody— no separator, affix on the wrong side of it. An explicitprefix/suffixnow re-sides the delimiter (_GEO+ prefix →GEO_,GEO_+ suffix →_MAT-style_GEO);autostill 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 thedelimiterparameter. -
2026-08-26 —
NamingConventionread 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 throughUserConfig.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; andreset()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/resetall name the one file the user owns, and_writediffs 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 intest/test_naming_convention.py; four of them fail against the previous layering. -
2026-08-25 —
MapFactory.extract_channelsis 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 ofprepare_maps' rivalry pass — it is what any consumer that cannot sample a channel needs, and mayatk's game shader now needs exactly it (a StingrayPBSTEX_*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_FORMATSalready named the encode-only container (KTX2) thatsave_imageroutes 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 everythingDELIVERY_FORMATSmeans, 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 Mayafilenode pointed at a 64x64.webpreportsoutSize0x0 where png / tga / jpg all report 64x64. -
2026-08-25 — A missing
toktxcan now be installed on demand:AppInstallergains thensisinstaller type and the sharedconsentseam (core_utils/app_installer.py,img_utils/ktx2_encoder.py,img_utils/_img_utils.py).Ktx2Encoder.resolve_toktxandImgUtils.resolve_ktx2_encodertakeauto_install/promptlikeMeshConvert.resolve_binary, fetching the pinned release (KTX_SOFTWARE_VERSION4.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, soAppInstallerlearned 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 throughShellExecuteExW'srunasverb and waits — the standard UAC prompt; the install still lands in the tool dir. Linux uses the plain tarball (bin/toktxfindslib/by$ORIGINrpath); macOS stays manual (a root-installing.pkg).promptnow also accepts a callable(question) -> bool— the seam a panel plugs its dialog into — via the newAppInstaller.consent, whichMeshConvert.resolve_binaryandUvUnwrap.resolve_enginenow share instead of carrying their own copies of the console[y/N](their messages andprompt=Falsecontract are unchanged). The "no toktx" error names the switch and is built in one place (Ktx2Encoder.not_installed_error, whichImgUtilsraises too). Thensistype is opt-in only — an.exeURL is never guessed into a type that runs the download — and the download's\rprogress 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_suffixadded 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 withnew_name.endswith(old_suffix), which is false for every append pattern — those keep the whole old name by definition, soSphere_GEOunder**_AbecameSphere_GEO_A_GEO, andMaster_GRPunder*_v2becameMaster_GRP_v2_GRP. The same test missed a numbered suffix, so a host's auto-numberedpCube_GEO1had its trailing token stripped and re-appended bare aspCube_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 literalstripentry was removed wherever it appeared in the name, not from the end (str_utils/_str_utils.py). The regex-vs-literal gate askedre.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 globalre.sub, against a docstring that says "strip from the end". Stripping a naming convention's_LOCtherefore ate the middle of a name: mayatk'screate_locator_at_objectturnedITA_LOCKHANDLEintoITAKHANDLE_GEOunderITAKHANDLE_LOCunderITAKHANDLE_GRP. A token now reachesreonly 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 tostrip_any_affix, which was already boundary-aware, and the remaining sites pass a single"_", forward the caller's ownstrip(resolve_name_collisions, which has no consumer outside this package), or strip nothing.test_str+1;test_format_suffix_list_stripre-expressed, since its["Cu", "be1"]case was asserting the mid-name removal itself. -
2026-08-25 —
MeshConvert.set_glb_alpha_mode+ thealpha_modescene-sidecar section (file_utils/mesh_convert/_mesh_convert.py). FBX2glTF decides a material'salphaModefrom the base colour's alpha channel alone: an RGBA base colour becomesBLEND, neverMASK, and an RGB oneOPAQUE— 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 writesalphaMode/alphaCutoffby material name (cutoffonly forMASK, a stale one dropped otherwise; unknown modes skipped with a warning, missing names reported like the other writers) and is registered inSIDECAR_APPLIERS, so a DCC-side reader — mayatk'sSceneState._read_alpha_mode— reaches the GLB through the existingapply_scene_sidecardispatch 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'sNaming.SUFFIX_TYPESheld two verbatim copies of a 19-row table,rig_utilshardcoded"_GRP"/"_LOC"/"_GEO"in its signatures,image_to_planedefaulted to"_MAT", and the Naming panel persisted the user's REAL convention onto nineteenQLineEditobjectNames where nothing else could read it — so renaming_GEOto_MSHmeant 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 existingsplit_affix/apply_affixprimitives), so a studio that writesGEO_bodychanges 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, andextappshas no DCC at all; what IS host-specific — resolving a live node to a type key — stays in mayatk/blendertkNaming.type_key. Storage followsUserConfigand 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_convention24 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 tostrip_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, sobody_GEOandGEO_bodyboth yieldbodyand 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.bindandStrUtils.delimit_affix, the two operations the toolset was about to duplicate.bindis 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_affixis the forgiving counterpart toapply_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 toMAT_or_MATon 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.