-
2026-08-22 —
ScratchTwins.createoverwrote a twin the user had saved real work into, which is the one file the class promises to keep (file_utils/temp_artifacts.py). The twin path is deterministic per source, so the secondcreatefor the same source lands on the same file — and it went straight toshutil.copyfilewith no existence or stamp check, silently discarding whatever was there. Two routes reached it: in-session, wherediscard_exceptcorrectly KEEPS a saved twin but stops tracking it, so the next open clobbers it; and across a host restart, where_stampsis process-local and every twin from the previous session reads as unrecognized — the more common one. The guarddiscardalready implemented was simply never consulted. Fixed at both halves: the stamp is now persisted to a<twin>.stampsidecar so the untouched-test survives a restart, andcreatemoves anything that is not provably our own untouched copy aside to<stem>_saved<N><ext>(logged at WARNING with the location) before writing the fresh conversion. Preserving rather than refusing is deliberate: both panels recompute the twin statically throughpath_forto answer "is this row the current scene?", so the path must not move, and refusing would leave the row permanently un-openable.discardreads the same persisted stamp and cleans the sidecar up. NOT a regression — the released mayatk did the identical unconditional copy onto a flat<temp>/<stem>_opened.maand never discarded twins at all; what was new is the invariant this class advertises.test_temp_artifacts+2 (..._never_overwrites_a_twin_the_user_saved_intocovers both routes as subtests;..._reuses_the_path_when_the_twin_is_untouchedpins that the common case does not accumulate copies). Both fail against the pre-fixcreate, checked by running them against it. The age-sweep test also had to age the sidecar:sweep_stalekeys off the NEWEST mtime under a directory, so "abandoned" means every file in it is old — in production the twin and its stamp are written together and age together. -
2026-08-22 —
HandoffBridge.carrier_of(path)+CARRIER_BY_EXTENSION, andStrUtils.strip_suffix(core_utils/app_handoff.py,str_utils/_str_utils.py). The carrier vocabulary now answers the inverse question too — which carrier a payload PATH names, every USD spelling (.usda/.usdc/.usdz) folding tousd, and aValueError(never a silent FBX) for an extension no carrier owns — which is what lets the DCC producers dispatch through a{carrier: writer}table instead of anif-chain per site.strip_suffixis the whitelist counterpart ofos.path.splitext: only a LISTED suffix comes off (asset.v2keeps its version token,asset.FBXloses its extension), the shape both Scene Exporters needed and had each re-implemented.CARRIER_PARAM/CARRIER_EXTENSIONS/CARRIER_BY_EXTENSIONreach the root (ptk.CARRIER_PARAM) besideUSD_EXTENSIONS.test_bridge+1,test_str+2. -
2026-08-22 —
HandoffBridgegrows a carrier: the payload's interchange format is a per-request choice on the one shared seam (core_utils/app_handoff.py).CARRIER_EXTENSIONS(fbx/usd) andCARRIER_PARAMare the vocabulary; a bridge declares the subset its target can read ascarriers(default("fbx",), first = default),carrier(request)/payload_extension(request)resolveparams["CARRIER"], and_runrefuses a carrier the bridge doesn't offer (or an unknown spelling) BEFORE anything is exported — the caller asked for something specific and must not get FBX silently. The template context exposes the payload as__PAYLOAD_PATH__beside the historical__FBX_PATH__(same path under both names; a template that must know the format reads the extension). Nothing here knows how either format is written — that stays with the DCC mixins.test_bridge.py+6 (HandoffCarrierTest): default, per-request choice, case, unoffered / unknown refusal, both tokens. -
2026-08-22 —
ScratchTwins(file_utils/temp_artifacts.py): per-source scratch twins of foreign files, discarded only while untouched. The "open a foreign scene as a new document" lifecycle both DCC Reference Managers had hand-rolled (and, before today, hand-rolled badly: a rawtempfile.gettempdir()/<stem>_opened.<ext>that two same-named scenes in different projects shared and nothing ever deleted).path_for(source)is deterministic —<temp>/<prefix>_<hash-of-source-path>/<stem>_<ext><extension>, soscene.matwins asscene_ma.blend: provenance in the host's title bar and Save-As default, never shadowing a siblingscene.blend, never colliding across projects;create(source, payload)copies + stamps (size, mtime_ns);discard(path)/discard_except(current)delete a twin only while it is still that untouched copy — one the user saved into is real work, kept and logged — and forget it either way. Built onTempArtifacts(one tracked, age-swept directory per source; 30-day default because a twin is a working document until saved elsewhere).test_temp_artifacts+5 (naming / per-source dirs / no creation on ask, copy + stamp, untouched-vs-saved discard,discard_except, age sweep of abandoned twin dirs). Found on the way and fixed inTempArtifacts.sweep_stale: a DIRECTORY was aged by its own mtime, which an in-place file rewrite (how Maya saves a.ma) never touches — a scratch dir someone kept saving into read as abandoned and would have been reclaimed with their work in it; a directory is now as fresh as its newest entry (+1 test; the existing reclaim test backdates the child too, as the premise requires). -
2026-08-22 —
RenamePlan+FileNaming(file_utils/file_naming.py) andStrUtils.retain_suffix: the batch-rename executor the DCC naming tools now share, and its file-system tenant.RenamePlan.apply(plan, rename, dry_run=, logger=, link=)takes(key, old, new)triples plus the ONE per-host strategy (the callable that renames a single item) and owns everything that used to be re-implemented per engine: dry run (plan only, nothing touched), the per-item error policy (one locked item never aborts the batch; a host that uniquifies is reported with the name it actually assigned), and the panel-ready report — onelog_grouprecord ofold → newlines (never a paragraph per item), an unchanged tally, and aresult/noticesummary. mayatk's and blendertk'sNamingengines route every operation through it, so a tool that redirects its logger into a pane gets the listing for free.FileNamingruns the same find / rename / convert-case / strip-chars grammar on file stems (the extension is never touched; a directory contributes its direct files only; an existing target is never overwritten) — the Directory / Files scopes of the Naming panels.StrUtils.retain_suffixis the "retain type suffix" rule that was copy-pasted in both DCC engines (_GRP1→_GRPcomparison, numeric tokens never retained, a recognized new suffix replaced, an unrecognized one kept).test_file_naming20 (executor contract + file ops on a temp tree),test_str+6. -
2026-08-22 — two DCC-agnostic unbake primitives:
IterUtils.find_extrema_indicesandMathUtils.fit_hermite_slopes. The key-selection half keeps endpoints, peaks, valleys and both boundaries of every flat run (sub-tolerance jitter on a hold never spawns keys); the tangent half solves one cubic-Hermite slope per kept key by least squares against every dropped sample (tridiagonal normal equations + a Thomas sweep — each sample couples only its two bounding keys, so a 27k-sample bake with thousands of extrema fits in ~0.15 s instead of a gigabyte-scale dense matrix), pins the slopes facing a hold to zero (the key's tangent breaks there, the other side stays fitted), and falls back to the sampled finite difference where adjacent kept samples leave a key unconstrained. Sibling offind_flat_interior_indices; mayatk / blendertkunbake_keysare thin DCC writers over the pair. One cubic per half-wave is the inherent limit (~2% of amplitude on a sine), and the tests pin that bound rather than pretend it away. -
2026-08-21 — an optional-Pillow guard that imported six names but bound only one left the other five UNDEFINED, so hosts without PIL got a
NameErrorinstead of the no-Pillow branch (img_utils/_img_utils.py,core_utils/engines/textures/map_factory/_map_factory.py). Both modules dotry: from PIL import Image, ImageOps, …/except ImportError: Image = None— and a name that the failed import never created is notNone, it does not exist. Every one of the 11 call sites behind those names (ImageOps×5,ImageFilter×6,ImageChops,ImageDraw,ImageMode,ImageEnhance) therefore raisedNameError: name 'ImageOps' is not definedin any interpreter that lacked Pillow at import time, in place of the fallback the guard exists to provide. Measured in Blender 5.1 (whose bundled Python ships no PIL), where it is worse than a clean failure: blendertk provisions Pillow on demand after pythontk is imported and then re-binds these globals by looking for theNoneones — a name that was never created is invisible to that repair, so the modules stayed broken for the whole session with Pillow installed and importable. Symptom: the blendertk Material Updater loggedError packing metallic/smoothness: name 'ImageOps' is not definedand silently shipped the raw Metallic map instead of the packedMetallic_Smoothnessthe preset asked for (the handler catches the exception and falls back). Bothexceptbranches now bind every name they import. Pinned bytest_img.OptionalPILGuardTest, which is static and repo-wide rather than a reproduction of this one case: it parses every module in the package and asserts each module-level guardedfrom PIL import …assigns all of its names in the handler, so a module added later cannot reintroduce the shape — and it fails in an environment that DOES have Pillow, where the runtimeNameErrornever appears. Fails against the pre-fix tree naming both files and all eight unbound names. Image/texture suites 540 passed. -
2026-08-21 — the run log kept unittest's report and threw away the OUTPUT that explained the failure (
test/run_tests.py). The tee was handed toTextTestRunner(stream=...), so it captured unittest's own writes — while a bareprint(), or thetraceback.print_exc()of an exception the product swallowed, went to the real stdout/stderr and never reached the log file. Measured 2026-08-20:FileUtils.get_file_contentsswallowed aFileNotFoundErrorand printed its traceback to stderr; the log showed onlyNone != [...], which is why SIX instances of the full-suite file-IO flake were filed as unexplained assertion failures when the cause had been printed and discarded. Both console streams are now teed into the log buffer for the duration ofrunner.run(suite)and restored in afinally, so a runner crash cannot leave the process writing into a buffer nobody reads. Nothing is doubled: the runner's own stream holds the ORIGINAL stdout object, captured before the redirect.TeeStreamhad to become a real stand-in to be assigned tosys.stdoutat all —writereturns a character count,writelinesis defined explicitly (delegating it would have written to the console and silently skipped the log), and everything else (isatty,fileno,encoding) is answered by the first stream, so product code asking stdout whether it is a terminal no longer raises mid-test. Two write failures are contained rather than raised, because a stand-in that raises turns an unrelatedprintinto a test failure: a cp1252 console cannot encode every character a docstring or log line carries (retried with replacements — note U+2014 is not one of them, cp1252 has it at 0x97), and a detached or closed stream is skipped for that write. Anything else still propagates, so a real bug is not swallowed.test_run_tests.TestRunLogCapture+3; two of them fail against the old runner, and the third — that the streams are restored — is the invariant that must hold either way, asserted INSIDE themock.patchbecause read afterwards it would pass with thefinallydeleted. 18/18.New public primitive:
ptk.TeeStream(core_utils/process_stream.py, besideOutputStream). Four of the ecosystem's six test runners had written their own near-copy of this class — diverging on the cp1252 guard, on whetherwritereturned anything, and on which failures were swallowed — and this change was about to add two more. It is the synchronous counterpart to theOutputStreamalready in that module (no threads, no line splitting, just a write-through fan-out), and it follows the same precedent asStatusBadge: an ecosystem-wide test-harness concern belongs at the bottom of the stack, not once per runner. pythontk's, tentacle's and uitk's runners now import it. mayatk's two copies deliberately stay — its suite driver's GUI entry point exec'srun_suitewithout thesys.pathsetup that makes the ecosystem importable, and its launcher capture runs before that setup exists; both now say so in place.test_process_stream.TestTeeStream+8, including the counterweight that an unexpected write error still propagates. 28/28. -
2026-08-20 — an unreadable file crashed a version bump with an unrelated
TypeError;FileUtils.get_file_contentsstops mis-declaring what it returns (core_utils/package_manager.py,file_utils/_file_utils.py).get_file_contentscatchesOSError, prints the traceback to stderr, and falls off the end returningNone— but it was annotated-> Nonewhile its docstring promised(list), and also claimed "Will create a file if one doesn't exist" when it opens"r"and creates nothing. Three statements about one function, no two agreeing. The only production caller,_PkgVersionUtils.update_version(:360), fed that result straight intoenumerate, so a missing or locked path raisedTypeError: 'NoneType' object is not iterablefrom inside a release version bump, with nothing in the message naming the file. Reproduced first as a failing test against a path that does not exist:TypeErrorat package_manager.py:366, the swallowedFileNotFoundErrorvisible only in captured stderr.update_versionnow returns""for an unreadable file — not a new behaviour but the one it already documents ("the new version number or empty string if not found"), since a file that cannot be read has no version to report. The annotation is corrected toOptional[Union[str, List[str]]]and the docstring now states the None-on-failure contract plainly so the next caller handles it. Found while chasing a full-suite-only flake, and it explains that flake's silence:traceback.print_exc()writes to stderr, whichrun_tests.pydoes not tee into its log (it tees stdout only), so the underlying OSError left no trace in the run output and the failure surfaced as a bareNone != [...]assertion. Whether swallowing the error at all is right is a contract question and stays the maintainer's — logged rather than decided here.test_package_manager.py+1 (fails with theTypeErroragainst the pre-fix code); those two modules 148 passed,run_tests.py3418 tests / 3403 passed / 0 failed / 15 skipped. The runner's own stdout-only tee — the reason the traceback vanished — is a cross-package harness change and is logged rather than made here. -
2026-08-20 — the map-type suffix boundary applies at EVERY alias length; the
len(alias) <= 3special case is deleted (core_utils/engines/textures/map_registry.py). The defect was never "the long branch forgot a boundary check" — it was that there were TWO boundary policies keyed on alias LENGTH, and the length key is meaningless. The evidence a filename offers that a trailing token is a suffix (a separator, a lowercase→uppercase step, or the alias standing alone) has nothing to do with how many characters the alias has. The 2026-08-03 fix installed_short_alias_boundaryonly where the bug had been measured — Agilent instrument model numbers, all ≤3 chars — instead of making it the rule, and then had to mirror that same threshold into the regex builder, so the threshold lived twice and drifted apart. 524 of the 570 registered aliases are longer than three characters, so ordinary asset names classified on a bareendswith. Measured through the live resolver:char_thigh→Height,wall_watercolor→Base_Color,panel_gunmetal→Metallic,prop_raincoat→Clearcoat,bone_abnormal→Normal,road_borough→Roughness,cloth_lipgloss→Glossiness,wall_damask→Mask,wall_afterglow→Emissive. The damage was worse than misclassification: it SPLIT TEXTURE SETS. Becauseget_suffix_strip_patterncarried the same threshold, the base name lost the false suffix too —char_thigh.pngbased tochar_twhilechar_thigh_Normal.pngbased tochar_thigh, so one material grouped as two sets. The fix is a deletion, not a widening: the length branch is gone from_match_aliasandshort_suffixes/long_suffixescollapse into one alternation, so there is one policy and nothing left to drift (the deferred entry prescribed "widen both branches together", which is a second boundary implementation — the third copy of the threshold). It also offered a false binary — separator/CamelCase or "require a non-alphabetic predecessor" — and the second option is measurably wrong, not a live alternative: every CamelCase-glued alias has a lowercase letter in front of it, so it would killmat_SpecularGloss→Glossiness (the fixture behind extapps' shipped Unpack SpecularGloss workflow),test_MetSmooth,test_SpecAlphaandrockBaseColor. Blast radius measured rather than argued: 2,295 spellings (four styles × 570 aliases) classified before and after — delimited authored-case 0 changed, delimited lowercase 0, CamelCase glued 0, all-lowercase glued 377. Every realistic spelling is untouched; the only class that moves is the ambiguous one the rule exists to reject. One improvement fell out that the corpus did not cover: a file named only after its map type used to base to the EMPTY string (Normal.png→'',Roughness.png→''), because the boundary-free branch matched the whole stem — so every such file collapsed into one anonymous texture set. A stem-initial alias has no lowercase character in front of it, so the attached branch no longer matches and the name survives (Normal.png→Normal); classification is unchanged. Pinned bytest_a_bare_alias_filename_keeps_a_usable_base_name. The one wart left is a mixed-case bare stem,BaseColor.png→Base(resolve matches theBaseColoralias at index 0, strip matchesColorat index 4) — arbitrary either way for a file with no asset name in it, and strictly better than the empty string it produced before._short_alias_boundaryis renamed_alias_boundary(private, absent from the registry) since its name was the last trace of the threshold.test_map_registry_short_alias_boundary.pygainsLongAliasBoundaryTest— the nine ordinary words, the CamelCase counterweight that rules out option B, the delimited-any-case counterweight, and the set-split check — all four failing first (12 failures, 9 subtests).test_map_registry_ambiguity.py::test_new_aliases_do_not_claim_ordinary_wordsstill passes but its docstring said aliases over 3 chars match with no boundary check, so it is corrected rather than left contradicting the code; alias hygiene still matters, since a delimited or CamelCase ordinary word classifies regardless (wall_metal→Metallic). Measured while the pattern was open: it grew 12,408→15,019 chars yet matches 2.7× faster (median 0.0470s→0.0172s, five runs of 1,200 substitutions, old pattern reconstructed in-process so nothing on disk moved), because a literal capital first letter lets the engine reject a position that(?i:…)had to attempt case-insensitively at every offset.run_tests.py: 3417 tests, 3402 passed, 0 failed, 15 skipped; extapps re-run as the downstream consumer, 605 passed. -
2026-08-20 —
validate-chain.ymlcompiles tentacle's.uitree recursively, so the downstream gate stops failing on files it never compiled. Thevalidate-tentaclejob enumerated two directories —tentacle/ui/*.uiandtentacle/ui/maya_menus/*.ui— andui/blender_menus/was added to tentacle after that step was written, so its 27 files were never compiled._ui.pymodules are gitignored build artifacts (tentacle/.gitignore:49), which is exactly why the omission stayed invisible: a developer tree already has them, so the pairing tests pass locally and only a fresh checkout can see the gap. In CI it failedtest_ui_integrity.py::TestUiFilePairing::test_blender_menus_pairingon all 27 stems, which failed thesummaryjob and reported the whole Validate Publish Chain workflow FAILURE — measured on release PRs #48 (2026-08-19) and #49 (2026-08-20), both of which merged anyway, because that workflow is not a required check. The cascade's only downstream-consumer gate had therefore been red and ignored on every release since blender_menus landed. Fixed by deleting the enumeration rather than extending it — adding the third directory would have left the same drift free to recur the next time a menu directory appears — so the step now walksui/withfindand covers whatever exists. Coverage verified by mirroring the real tree into a sandbox and running the exact step body against a stubpyside6-uic: 91 → 118 files (58 root + 33 maya_menus + 27 blender_menus), every derived output path still<stem>_ui.py, including the#that every submenu stem carries.find -execwas written first and rejected: GitHub runs arun:block underbash -e, so the oldforloop aborted the step whenpyside6-uicfailed, butfind -execreports success even when the command it ran failed — measured, it printed "REACHED END" and exited 0 on a stubbed failure, which would have compiled 117 files, skipped the broken one silently, and left the pairing test reporting a missing_ui.pyinstead of the actual uic error. The shipped form iswhile IFS= read -r ui; do ... done < <(find ...), which keeps the fail-fast (verified: exit 1, with the uic error surfaced) and still tolerates a space in a path. tentacle's owntests.ymllisted all three directories and was NOT broken; it got the same treatment purely so the two copies of this step cannot drift apart again. -
2026-08-20 —
ruff check pythontk/ test/is green on a clean tree (test files only). Seven residual cosmetic findings blocked the ruff CI gate from being switched on: two E702 semicolon statements (test_map_compositor.py), two E731 lambda assignments rewritten asdef(test_math.py,test_module_reloader.py), two dead imports removed (test_map_factory_grouping.py,test_script_run.py), andtest_net_utils.py's deliberate availability probe marked# noqa: F401rather than deleted — it is a probe, not a use, and removing it would silently drop the guard. No production code touched; the six affected modules pass unchanged (343 tests). The gate itself stays off — it is monorepo-wide and still blocked on the residual findings in the other packages. -
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. One shape declines the move rather than risk the file: a first buffer that declares auri(EXTERNAL — there is no BIN to append to, and writing one would strand the appended views while overwriting that buffer'sbyteLength), which keeps the base64, since a size cost is not a lost byte. A second hazard was fixed at its root instead, because it was never this pass's alone:replace_restrebuilds every byte after the JSON chunk from the one payload it is handed, so any further chunk — GLB is a chunked container, and the spec tells a client meeting a type it does not know to IGNORE it, not discard it — was being deleted by whichever of the three repackers ran (this relocation,optimize_glb_textures,prune_glb_unreferenced_textures). Nothing here decodes such a chunk, so nothing here could put one back.replace_restnow carries the slice past the BIN over verbatim (_trailing_chunks) and emits the BIN first, where the spec wants it — so the result is valid even for a file that arrived with no BIN at all, and relocation no longer has to decline. Both shapes are pinned failing-first. 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+5,test_preview_server.py3 retargeted (159 + suite green).