-
2026-08-05 —
find_str_and_formatsilently ignored the filter on every input more complex than one plain wildcard term. The "from" text was derived once asfltr.strip("*"), which is only meaningful for a single term. A pipe-separated filter collapsed into one literal ('pCube*|nurbs') that appears in no string, so*chars*and strip became no-ops and*chars/chars*degraded to a blind append — strings matched the filter and then came back unformatted, with nothing to indicate the format had been skipped. Regex mode had the same shape of defect for a different reason: the compiled pattern drove the search only, while the substitution ranstr.replace/str.splitagainst the raw regex source, andstrip("*")corrupted that source (.*Cube.*→.*Cube.);ignore_casenever reached the substitution at all, because the case-folding pattern was built onlyif frm_ and ignore_case and not regex. Measured before:find_str_and_format(['pCube1'], '*box*', r'pCube\d+', regex=True)returned['pCube1'], unchanged.The formatter is now term-aware: each string is re-matched against the filter's terms, and the term that matched supplies its own "from" text — so multi-term filters format correctly, and pipe-separated
toterms pair positionally with them (fltr '*_L|*_R'withto '*_lt|*_rt'renames each side differently; a singletoterm still applies to every filter term, and a pipe intostays literal when the filter has no terms to pair with). In regex mode the compiled pattern drives the substitution as well as the search, so|stays alternation rather than a term separator, asterisks keep their regex meaning, and the filter's capture groups are available intoas\1/\2/\g<name>— an escape that cannot be expanded, or a backref naming a group the pattern lacks, is used verbatim rather than raising. That second case was caught by fuzzing rather than by review:re.subreports an unknown group name asIndexError, notre.error, so the substitution path'sexcept re.errorlet it escape while the equivalentexpandpath (which already caught both) did not. A 2128-combination sweep overto×fltr×regex×ignore_casenow reports zero raises, which is what makes the mayatk simplification in that package's entry safe. Mode inference carried a third, separate bug: thereplace_charstest caught all-asterisk patterns first, so**— an append of an empty suffix, i.e. a no-op — deleted the matched token instead; the**tests now come first. The blind-append fallback for replace-prefix/suffix is deliberately kept (it is what makes*_GEOagainst an empty filter suffix everything) but is now documented rather than implicit.Making the formatter term-aware surfaced a fourth defect underneath the others: filtering was delegated to
find_str, which deduplicates. Two equal input strings therefore collapsed to one result, so a filtered rename of two objects sharing a short name formatted only the first and left the second silently untouched — the no-filter path was unaffected because it formats one name at a time, which is why mayatk's existing duplicate-hierarchy test never caught it.find_str_and_formatnow does its own filtering in the same pass that resolves the matching term, preserving input order and duplicates;find_strkeeps its dedup, which is correct for a search. The wildcard term grammar those two share is no longer written twice: new privateStrUtils._parse_wildcard_terms/._match_wildcard_termown it, andfind_str(now a classmethod, matching itsfind_str_and_formatsibling —clsis bound, so callers are unaffected) consumes them, which also drops the redundant second matching pass the first cut of this fix introduced. +22 tests, with the 7 pre-existing ones left untouched to pin the unchanged grammar; 2513 green. Consumed by mayatk's and blendertk'sNaming.rename, whose panel tooltips now document the grammar the code actually implements. -
2026-08-05 —
PluginInstallershort-circuited on mere presence, so a DCC plugin installed once was never updated again.install_pluginreturned early ondest.exists(), and its symlink path — the one that would have tracked the source — needs Developer Mode, so every ordinary Windows machine landed on thecopytreefallback and froze the install at whatever shipped that day. A package update adding an op left the DCC answering unknown op, which surfaces as the feature quietly not happening rather than as an error. Measured: Painter'ssubstance_rpchad noproject.set_resolution/bake.set_high_poly/textures.apply_mesh_maps(9 ops installed vs 12 shipped), so mayatk's Substance panel sent 4K Map Resolution and Painter stayed at its own 1024 default. Installs are now content-gated: newPluginInstaller.is_plugin_currentcompares every shipped file (symlink installs are current by construction),install_pluginrebuilds on drift, andforce=Truenarrows to "rebuild even when it already matches". A matching install is still left untouched, so per-hand-off calls stay cheap — and files at the destination with no source counterpart (the DCC's own__pycache__) are not drift.test_rpc.pycovers add/edit/nested drift, the no-rebuild path, and the symlink case. -
2026-08-04 — The texture taxonomy was blind to two everyday filename conventions: lowercase short suffixes and UDIM tiles. Both were silent — the maps grouped into a set and then vanished from it.
(1)
rock_ao.pnggrouped asrockbut classified as nothing.get_suffix_strip_pattern's underscore branch has always been case-insensitive for every alias, whileresolve_type_from_pathdemanded a capital first letter for short (≤3 char) aliases regardless of what preceded them. So the base-name stripper took_ao/_nrm/_rgh/_bc— putting the file in the right texture set — and the resolver then reported no map type, so the shader builders dropped it as unrecognized. Measured: arock_basecolor/_nrm/_ao/_rghset wired base color and nothing else. The case rule now applies only where case is the sole evidence a suffix exists — the ATTACHED CamelCase boundary (rockNyes,wood_greenno, or every word ending in an alias letter would classify). After an explicit separator the suffix is a deliberate authoring decision, so it matches in any case, which also brings the classic_d/_n/_sconvention in. The model-number guard that motivated the original rule is untouched: it keys off a digit/uppercase predecessor (Agilent_E4419B), not case.test_map_registry_ambiguity.py/test_map_registry_short_alias_boundary.pyupdated — the old blanket-case test asserted the behaviour that caused the drop.(2) Every UDIM tile classified as None.
os.path.splitextremoves only the extension, sorock_Normal.1001.pngreached alias matching asrock_Normal.1001— ending in no alias at all. Measured before:group_textures_by_setshattered a 2-tile × 3-map set into six single-file "sets" andsort_images_by_typereturned{}, i.e. MapFactory and both game-shader panels were complete no-ops on UDIM. NewMapRegistry.split_tile_tokenis the single owner of the tile grammar (.1001in the real 1001–1999 range,<UDIM>/<UVTILE>,u#_v#), consumed byresolve_type_from_path,get_base_texture_name(drops it — that is the material's name) and the newMapFactory.get_tile_token(reads it back). The bare_####form is deliberately NOT recognized:_1024/_2048is the everyday resolution tag and 1024 sits inside the UDIM range, so accepting it would renamewall_Normal_1024.png. Each tile stays its own texture set (one tile is one image to convert; collapsing them would overflow the factory's one-path-per-map-type inventory and drop all but the last), andTextureProcessor.tile_tokenre-appends the token after the map type so outputs stay a readable sequence Maya and Substance still read as tiles —rock_Normal_OpenGL.1001.png— and two tiles cannot resolve to one path. Verified end to end: a 2-tile set converts per tile, green-flips per tile, and writes no colliding name. -
2026-08-04 —
MapFactory.resolve_normal_maps: reduce an inventory to ONE normal map, optionally in a requested convention. The sibling offilter_redundant_maps, for the redundancy that one cannot see:Normal/Normal_OpenGL/Normal_DirectXhave noreplacesrelationship, so redundancy filtering never collapses them — yet all three drive the same shader input, and a set carrying two wired it twice with the last connection silently winning. Same contract as its sibling (mutates{map_type: path-or-[paths]}in place, returns{"dropped": …, "converted": …}), so both DCC packages fold it into the inventory pass they already run.target_formatis a parameter, not a branch, because that is the only part of the problem this layer cannot know: a renderer whose shading graph can flip green does it there and passesNone(blendertk), one that cannot has to correct the FILE and names its own convention (mayatk, whose bump2d has no flip attribute). The ambiguous genericNormalis never converted — its convention is unknown, so flipping it would invert a map that may already be right. The converted source is reported underdroppedas well asconverted: a caller mapping the report back onto real paths would otherwise keep the original file beside the new one and reintroduce the double-wire.test_map_factory_grouping.py+9. (This logic first landed inside mayatk'sGameShader; it touches no DCC API at all, so it belongs here — the app-agnostic rule.) -
2026-08-04 —
MapRegistry.NORMAL_TYPES/select_normal_type: one owner for "which normal map does a shader wire".Normal,Normal_OpenGLandNormal_DirectXare three distinct map types, so redundancy filtering never collapses them and a set carrying two drives one shader input twice. Both DCC packages had to pick one, both hardcoded the precedence, and — measured — they had already drifted: blendertk tried the ambiguousNormalFIRST, shadowing a labeled map with an unlabeled one and then guessing its convention from a combo box, while mayatk preferred the explicit tag. Same placement rationale asLOGICAL_CHANNEL_TYPES: the two consumers cannot import each other, so the table belongs here.output_template._NORMAL_TYPESandNormalMapHandler.get_consumed_typesnow read the same constant instead of keeping their own copies — a normal type added to the registry would otherwise silently keep the default output container and leak through as an unhandled passthrough map.test_map_registry_ambiguity.py+5. -
2026-08-04 —
detect_normal_map_format: measured, and its one blind spot written down. No behaviour change. Stress-tested over synthetic height fields × {clean, JPEG q40–q70, quarter-res}: 40/42 correct, 2 indeterminate, 0 wrong-sign; real normal maps land at |r| ≈ 0.64–0.95 against a 0.25 threshold, and photographs, random noise, flat fills and OBJECT-space normals all fall below it and return None rather than guessing. The limitation worth knowing: the statistic is the relative handedness of two channels, so it cannot separate "G is inverted" from "R is inverted" — a red-flipped map (an X− bake, a mirrored-UV export) reports the opposite convention confidently. Recorded in the docstring together with why it is contained: the caller only consults it for a map that classified as the ambiguous genericNormal, never to override aNormal_OpenGL/Normal_DirectXfilename tag. -
2026-08-03 —
UvUnwrap: documented that the two engines disagree on UV indexing, not just layout. No behaviour change; the class docstring previously promised only that both engines return the input topology unchanged, which led a consumer to assume the UVs came back equally well-formed. They do not: BFF deduplicates itsvttable and shares an index wherever two faces meet inside an island, while Ministry of Flat writes onevtper face corner even when the coordinates are bit-identical. A consumer whose UVs are indexed rather than per-corner (Maya — not Blender, which stores UVs per loop) therefore reads every edge as a UV border unless it welds coincident UVs after import. This cost mayatk'sauto_unwrapa shipped bug (see mayatk 2026-08-03); recorded here so the next consumer doesn't rediscover it. -
2026-08-03 —
AppLauncher.process_environ: the OCIO hand-off strip was reading a snapshot that could never contain the variable it exists to strip.handoff_env(below, 2026-08-02) was built onos.environ— but that mapping is a snapshot taken at interpreter init, and Blender 5.x setsOCIOto its bundled v2.5 config at the C level AFTER embedding Python (during color-management init). Measured live: insideblender --background,os.environ.get("OCIO")isNonewhilectypes GetEnvironmentVariableW("OCIO")and a childcmd /c echo %OCIO%both return Blender'sdatafiles/colormanagement/config.ocio. So the strip never fired,handoff_envreturnedNone(= inherit), and every bridge-launched Maya still inherited the unloadable config —Warning: Color Management Initialization failed … is version 2.5 … not able to load that config versionas the FIRST line ofmaya -log, the exact live report. Newprocess_environ()decodes the LIVE environment block viaGetEnvironmentStringsW(skipping the hidden per-drive=C:=…CWD entries; POSIX returnsos.environ— the same C-level blindness exists there but no supported host exercises it);handoff_envnow reads and copies from it. Existing tests keep passing becauseos.environ.__setitem__/__delitem__callputenv/unsetenv, sopatch.dict-driven values reach the real block too. Verified live: re-ran the send e2e's GUI leg — the launched Maya's log carries zero color-management lines. -
2026-08-03 — Hand-off bridges gain a second delivery SHAPE:
save_as— run the target app headlessly and keep the file it wrote. The kit could only ever launch an app and return; "give me a.blend/ a.ma" had no route through it, even though every ingredient existed (ScriptRunner.run_script_to_artifactalready ran a DCC blocking and judged it by its artifact — that is what the pull-direction engines use). The missing piece was a seam letting ONE bridge deliver two ways off ONE export pipeline. Three small additions, no rewrite:HandoffBridge.deliverersmaps a request mode to aDeliverer(absent mode → the existingdeliverer, so every current bridge is untouched);ScriptRunDelivereris the blocking sibling ofScriptLaunchDeliverer, inheriting its template discovery / rendering / env sanitizing and replacing only the launch — the artifact path ridesrequest.extras["output"]and reaches the template as__OUT_FILE__;ScriptLaunchBridge.save_as(out_path, …)wires it up, withrun_spec(a secondScriptLaunchSpec— headless argv, and a different executable where the target has one:mayapyvsmaya.exe, resolved through the newHandoffBridge.headless_app_path) andsave_extensions(a bare path gets the default).objects=Nonemeans the WHOLE SCENE here via the new_scene_objects()hook, not the selection — "save the scene as ..." is about the scene;_resolve_objectskeeps its selection-first meaning forsend(). The target app writes a staging sibling that is promoted withos.replaceonly on success:run_script_to_artifactclears the artifact path before running (a leftover would fake success), which is right for a cache artifact and destructive for a save-over — without staging, "save over my scene" would take the previous file with it the moment the child app failed. The sibling keeps the extension, because templates branch on it (.mb→ mayaBinary), and sits in the same directory so the promotion is an atomic same-filesystem replace. The two failure paths are deliberately opposite: a failed run sweeps the sibling (a killed child leaves a partial file, and an EMPTY one is a failure the runner reports without removing — neither may litter the output folder), while a failed promotion KEEPS it and names it in the error, because at that point the scene is written and only the rename failed — discarding the result the user waited minutes for would be the worse bug. Consumed by both DCC packages (see their CHANGELOGs); live round trip verified in both directions. -
2026-08-03 — Mode declarations are read STRICTLY where the template contract is non-negotiable.
ScriptTemplate.template_modesfalls back to the caller's primary mode when a template declares nothing — deliberate, so a custom template a user drops into a bridge folder just works. But the fallback also fires when a template declares only OTHER modes, so the interactiveimportrecipe (BRIDGE_MODES = ("send_to",)) validated as asave_astemplate: it has no__OUT_FILE__and never saves, so the run would have failed on the missing artifact minutes later instead of in preflight. NewScriptTemplate.declared_modesreturns the raw declaration,Nonewhen there is none — the distinction is the point, since it is what makes the lenient fallback safe to apply only where it belongs.ScriptLaunchDeliverer.strict_modes(False) /ScriptRunDeliverer(True) pick the reading;template_modesis now expressed on top ofdeclared_modes, so there is one parser.test_bridge.py+14. -
2026-08-03 —
ScriptLaunchBridge.render_contexthas a working default instead ofNotImplementedError. Every template in the ecosystem is Python, andreprrenders bool / str / number exactly as a.pytemplate needs — so the base now formats params itself and subclasses override only to reach a richer formatter. That is not cosmetic: the DCC bridges' formatter lives in uitk (Qt), andsave_asmust run where Qt cannot be imported (headlessblender --backgroundhas no Qt binding — verified on 5.1), so both bridges now fall back to this when the panel stack is unavailable.ScriptLaunchSpecalso gainedtimeout(blocking route only). -
2026-08-03 —
TempArtifacts.dir_path(): scratch DIRECTORIES are first-class, and cleanup/sweep are no longer file-only. The class managed files but not directories, so every site needing scratch space hand-rolledtempfile.mkdtemp+finally: shutil.rmtree— re-implementing the lifecycle per site, and getting no reclamation at all when thefinallycould not run (a DCC crash mid-operation leaves nothing to execute it).dir_path()allocates a tracked directory in the same prefix namespace;cleanupnow recurses into directories (os.removecannot delete one) andsweep_stalereclaims stale ones by age, so an abandoned scratch tree is collected by a later run instead of leaking forever.__exit__also switched fromisfiletoexists, or a failed run reported "nothing kept" while a whole tree sat on disk. Audited every allocation across the ecosystem and routed the raw ones through the primitive; new gatem3trik/scripts/check_temp_artifacts.pykeeps it that way.test_temp_artifacts.py+9 → 45. -
2026-08-03 — Two temp leaks fixed in pythontk itself.
NetUtils.connect_rdpwrote a.rdpwithmkstempand deleted it only on the FAILURE path — mstsc reads the file after the call returns, so the success path (the common one) leaked one per connection with nothing to reclaim it.UsdUtils' twomkdtempscratch dirs andMapOptimizer._encoded_size's dry-run scratch had afinallybut no safety net if the process died. All now allocate throughTempArtifacts, keeping their existing cleanup and gaining the age-gated sweep. -
2026-08-03 —
MapRegistry.resolve_type_from_channel+LOGICAL_CHANNEL_TYPES: the join between a shader's INPUT and a map's TYPE. A bridge manifest records which logical channel each texture was read from (baseColor,normal,ambientOcclusion, …); the registry knows map types. Both DCC packages need the mapping and cannot import each other, so it belongs here. Deliberately scoped as a fallback, never a replacement for filename classification: a channel says only which socket consumed a file, not how it is packed — anMSAOwired into a metallic slot is still an MSAO, and only the filename reveals that. Consumed by blendertk's manifest replay for files that classify to nothing (see mayatk/blendertk CHANGELOGs).test_map_registry_short_alias_boundary.py+3 (every channel resolves to a REGISTERED map type, case-insensitive, unknown → None). -
2026-08-03 — Short map-type aliases matched glued onto model numbers, silently wiring color maps into the wrong socket.
MapRegistry.resolve_type_from_pathmatched aliases of <=3 chars as a bare trailing substring with no boundary check, so a texture named after a hardware model number resolved as a map type: measured on a real production scene (VDATS_RF),Agilent_E4419B.png-> Bump,Agilent_PSG.png-> Glossiness,Agilent_8757D.png-> Diffuse. All three are plain color maps, so the bridge's material rebuild wired them into normal/glossiness inputs — worse than not classifying at all, because it is silent. The sibling path (MapFactory.resolve_map_type(key=False)) had always required an underscore boundary and documented exactly this hazard ("avoid mid-word matches likediffuse_cubematching the single-letter aliasE"); the two disagreed. A short alias now needs a real boundary: a separator (rock_AO), a lowercase->uppercase CamelCase step (rockN— the existing case-sensitivity rule exists precisely to support this, so it is preserved), or the alias standing alone (N.png). A digit or uppercase predecessor is rejected.get_suffix_strip_patterngot the same boundary, which is the half that is easy to miss: it is a SECOND implementation of "does this name end in a map-type suffix", feedingget_base_texture_name/group_textures_by_set. Tightening only the resolver left the two disagreeing —Agilent_E4419Bclassified as None but still had itsBstripped, soAgilent_E4419AandAgilent_E4419Bcollapsed into ONE texture set.test_map_registry_short_alias_boundary.py(new, 6 tests) pins both the boundary and the strip/resolve agreement itself; full suite 2419 green, so nothing depended on the loose matching. -
2026-08-03 —
HandoffBridge.import_roots: make a toolkit importable in a launched child app. A launched app does not inherit the parent's importable set, and Blender ignoresPYTHONPATHoutright (verified on 5.1 — the variable is present in the child's environment and never reachessys.path). A bridge template that imports a toolkit to do post-import work therefore fell through to its "toolkit unavailable" branch every time, silently. Returns only the roots for the named packages — never the parent's wholesys.path, which for a cross-version child (Maya's 3.11 -> Blender's 3.13) would prepend the parent'ssite-packagesand shadow the child's stdlib with binary-incompatible modules. Namespace-package aware: with a monorepo root onsys.path,<repo>/pkg/resolves as an EMPTY namespace module whose naive parent directory is the unusable repo root, so when a spec has no real__init__.pyit looks one level in for the actual package. Consumed by mayatk's Blender bridge (see its CHANGELOG).