-
2026-09-05 ΓÇö the console consent seam no longer trusts
sys.stdinto know whether it is a terminal (core_utils/app_installer.py).AppInstaller.consent(True, ...)calledsys.stdin.isatty()bare. Maya's GUI swapssys.stdinfor aStandardInputproxy with noisattyat all, so every failed-check export in a GUI Maya died with anAttributeErrorinstead of answering no (7 errors in mayatk's 2026-09-04 GUI pass), and a closed stream raisesValueErroron the same call. The probe now lives in_console_can_answer(): no stdin, noisatty, or a raising one all read as "nobody to ask" (None), never a traceback and never a read that would block a host application. Pinned bytest_app_installer.py. -
2026-09-04 ΓÇö a 16-bit colour PNG no longer needs OpenCV (
img_utils/_img_utils.py).ImgUtils.save_image(..., bit_depth=16)wrote a 16-bit RGB / RGBA PNG only whencv2imported, and silently fell back to 8 bits otherwise -- which is exactly the machine that needs it: mayatk's horizon preview measured Maya's OpenGL path sRGB-decoding any 8-bit texture aGLSLShadersamples (Raw colour space, colour management andMayaGammaCorrectionchange nothing), and a 16-bit texture is the fix because sRGB is an 8-bit-only format. New_write_png16writes the file with the standard library (one IDAT, filter type 0, big-endian samples); TIFF keeps the OpenCV path.test_img: the 16-bit colour test decodes the file without OpenCV and pins every sample to the byte times 257, plus a read-back throughcv2where it is present. -
2026-09-04 ΓÇö the pointer pass is a channel TABLE, and
highlightis its second row (file_utils/mesh_convert/glb_fades.py,_mesh_convert.py,net_utils/preview/viewer.html). Asked as "how might we animate object highlighting in Maya for WebXR and Unity like the reference video, without building a full tool per effect". The fade pass generalises into one pass overglb_fades.CHANNELS-- per row thevisibility_trackskey carrying the ramp, the material property it lands on (opacity->baseColorFactorVEC4 alpha;highlight->emissiveFactorVEC3, additive and composed over the material's own emissive so an LED panel that is also highlighted keeps glowing at intensity 0), whether the clone needsalphaMode BLEND(alpha only) and an optional per-node colour key (highlight_color). Three things the one-channel pass got wrong for a second channel, each pinned before the change (TestApplyGlbHighlight, 10 tests): (1) the idempotence guard was per FILE (any channel is a pointer), so a channel added after another was written returnedNoneat DEBUG -- it is now per pointer TARGET; (2) a node carrying two channels was isolated twice -- it is isolated ONCE with BLEND only when a channel on it needs it, and a material used by nothing outside the subtree that already carries what the channel needs is animated in place (the ORIGINAL is never mutated: the sidecar repairs it by name, and an authoredMASKturnedBLENDwould pop at no cutoff -- the existing clone tests pin this); (3) the fade pass bailed with no declared takes where the visibility gate already fell back to the tracks' own extent -- a highlighted prop exported without a shot list shipped no highlight. Also: a cleared carrier channel ("") now reads as absent instead of logging "unparsable" on every conversion; newMeshConvert.strip_glb_curve_proxiesremoves the DCCs'curveProxy-stamped transport nodes (a child<node>__<attr>whosescale.xcarries a curve for engines that flatten custom-property animation) BEFORE the clip rebuild counts their channel as content -- measured:hlCube__highlightarrived in the GLB with its 0->1 scale intact; runs first infbx_to_glb. The preview's fade shim is now a generic material-pointer polyfill (POINTER_BINDINGS:baseColorFactor->material.opacity,emissiveFactor->material.emissiveas a colour track), which is what a realKHR_animation_pointerimplementation does for materials;test_preview_viewer_live+1 (a highlight ramps the emissive in the real page, does not blend, and does not reach the untouched sharer).test_mesh_convert+10. -
2026-09-04 ΓÇö the viewer derives the horizon frame's up from the contact, not from a cross product (
net_utils/preview/scripts/shadow_rig.js). A Blender-exported horizon rig drew NO shadow: itsframe_barrives as the file's-Z(Blender bakes bearing from local X toward local Y, which the Y-up conversion mirrors), so thecross(B, A)up the previous entry introduced came out-Yfor that frame, every source read as below the horizon, and alpha was 0 at every point -- measured before the fix. Maya's(X, Z)frame happened to give+Y, and every fixture used it. The up is now the contact node's own+Yin world space, whatever the bearing sense;test_shadow_web.pygains a Blender-frame pin (a map baked in the mirrored sense, the reference fed frame-mapped points, 48 rendered ground points) that fails on the cross product with a worst disagreement of 1.0. -
2026-09-04 ΓÇö the bootstrap every package boots through parsed each module twice (
core_utils/module_resolver.py)._is_safe_to_lazy_loadand_scan_module_attributesboth need the AST of any module on the lazy path, and neither shared it: measured at 13 read-and-parse calls across 7 distinct modules for pythontk's ownbuild(), with six modules read off disk and parsed twice apiece. Every package in the ecosystem boots through this._parse_modulenow memoizes into a cache thatbuild()clears on entry and exit, so a rebuild always re-reads from disk ΓÇö which is whatModuleReloaderrelies on, and is pinned by its own test ΓÇö and the trees are not retained afterwards. Neither consumer mutates the tree, checked before sharing it. The real work moved to_read_module_ast, so the tests count files actually read rather than calls to the memoized accessor, which is deliberately unchanged; the first version of those tests counted the wrong thing and passed for the wrong reason. Measured interleaved, alternating the cache on and off across eight runs so drifting machine load could not favour either arm: 87.1 ms against 144.2 ms (median of best-of-15 per run), a 40% reduction, with the two arms not overlapping at all ΓÇö memoized worst 99.7 ms, un-memoized best 124.4 ms. An earlier single-shot reading showed the opposite and was discarded: the full test suite was running on the same box. No API surface changes. -
2026-09-04 ΓÇö the compositor's two raw PIL saves now go through the writer (
core_utils/engines/textures/map_compositor.py). They were the only twoImage.save()calls left in the texture engine, bypassingImgUtils.save_imageΓÇö which dispatches on the extension: PIL for the ordinary containers, OpenCV for the float formats, the external encoder for KTX2, plus the bit-depth and lossy-quality rules every other texture write in the package already obeys. This is a live crash, not hygiene. The engine discovers its inputs withImgUtils.texture_file_types, which listsexrandhdr, and then takes the output extension from the first input ΓÇö while raw PIL can write neither. Reproduced end to end: a.exrset throughprocess_batchraisedValueError: unknown file extension: .exrwith zero files written, and the same set now writes. The second save is the inverted-green normal counterpart, which had the identical hole. In the same pass the module gets the guardednumpy/PILimports its six siblings in this engine already have ΓÇö it imported both bare, so an install without Pillow failed at import rather than degrading. The try/except alone was not enough, and a test that grepped the source forexcept ImportErrorpassed while the module was still broken: this module has a module-levelLayers = List[Tuple[str, Image.Image]]alias and sevenImage.Imageannotations, all evaluated at import, so aNoneImage died onAttributeError: 'NoneType' object has no attribute 'Image'two lines later ΓÇö which is exactly what.claude/BACKLOG.md's 2026-08-21 entry warned would happen to a copy-the-siblings fix.from __future__ import annotationsdefers the annotations and the alias is quoted (a plain assignment, which__future__does not defer). The test now denies PIL and imports the module for real. A control test pins that the ordinary PNG path is unchanged. -
2026-09-04 ΓÇö the task pipeline reports progress (
core_utils/task_factory.py).TaskFactory.progress_callbackΓÇö the ecosystem's(current, total, message)shape, the one uitk'sprogress_adapterreads ΓÇö is called before every run-list entry ("Task 3/9: optimize_textures") and once more to close the list, so a consumer can drive a determinate bar without knowing the pipeline;_report_progress(None, None, text)is the text-only tick for a subclass narrating a long entry. An explicitFalsefrom the callback raisesOperationCancelledbefore the next entry runs (the set/revert unwinding still runs) ΓÇö the contractprepare_mapsadopted the same day;Noneis not a cancel, and a raising callback is logged at DEBUG and never allowed to fail the run. First consumers: the mayatk and blendertk scene exporters, whose footer bar and Esc-cancel ride this. Public surface: +1 attribute. -
2026-09-04 ΓÇö the package stops mis-describing itself: annotations, class docstrings, and the placement rule (
docs/README.md,pyproject.toml, 15 modules).@listifychanges a function's published return type ΓÇö hand it a list and it returns one ΓÇö but the annotations described the undecorated body.FileUtils.convert_to_relative_pathwas annotated-> strand returns a list, whichinspect.signature,help()andAPI_REGISTRY.mdall republished;format_pathhad carried the right shape (Union[str, List[str]]) the whole time, so this was drift rather than an open question. All nine listified functions are now annotated ΓÇö six had no annotation at all, against the type-hint-public-signatures standard ΓÇö and a guard test asserts by AST that none of them ever promises a scalar-only return again, with the premise (that the wrapper really does return a list) measured rather than assumed. Eleven published classes carried the placeholder docstring"<Name> ΓÇö module namespace.", a refactor stub that reached the published surface ΓÇö includingShotPlanner(the entire timeline planning layer),ManifestModelandPluginInstaller. A stub is worse than no docstring, because it looks answered; all eleven are now written, and a second guard sweeps every public class for placeholder first lines (it also asserts it found 100+ classes, so it cannot pass by sweeping nothing). That sweep immediately caught an invalid escape sequence (\ sinstead of\ s) in a docstring written minutes earlier in the same pass. Finally the README claimed domain pipelines "are compositions of these, assembled downstream" whilecore_utils/engines/holds 19,944 lines of exactly those pipelines ΓÇö texture/PBR conversion at ~11.9k and the shot timeline at ~6.4k, measured. That sentence is what makes every audit re-litigate placement, so the real rule is now stated: a domain pipeline lives here once more than one host drives it, because "shared code moves down" outranks "placed by data type" and the alternative is two drifting copies. The PyPI blurb, which listed only the primitive domains, now names the engines too. -
2026-09-04 ΓÇö the version poller is joinable and no longer duplicated;
pkg_resourcesis gone (core_utils/package_manager.py).start_version_checkended inthreading.Thread(..., daemon=True).start()ΓÇö no handle, no join, no dedup ΓÇö andcheck_versionis a pip subprocess against another interpreter plus a PyPI round trip. The live caller starts it from a Qt slot constructor: tentacle'sHudSlots.__init__pollstentacletkagainstmayapy.exe, so each construction of that panel launched another. Measured: five constructions, five concurrent threads, nothing able to observe or await any of them. The thread is now returned and kept on the instance, namedptk-version-check-<pkg>so it is identifiable in a stack dump; a check already in flight is reused rather than duplicated (re-measured: one thread for the same five calls), and finishing one still permits the next, so the dedup is not a one-shot latch. Newversion_check_runningandwait_for_version_check(timeout)let a host shut down tidily instead of relying on the interpreter killing a daemon thread mid-pip.start_version_checknow returns the thread instead ofNone, which no caller is affected by. Separately,update_requirementsresolved versions throughpkg_resourcesΓÇö a setuptools import deprecated since 67.5 and removed outright in 81, so it would become a hard ImportError, in a package targeting Maya's and Blender's bundled Pythons;importlib.metadatahas been stdlib since 3.8 against a 3.9 floor. Fixing that surfaced a worse defect in the same loop: the version lookup sat in atrywhoseexceptprinted and appended nothing, while the function writesupdated_linesback over the file ΓÇö so a single package that could not be resolved silently deleted its requirement line. Reproduced ([]where the line should be) before the fix, which keeps the original line and still reports. The method has no callers in any of the seven packages and had no test; it has three now. -
2026-09-04 ΓÇö the texture engine no longer patches its own submodules at import time (
core_utils/engines/textures/map_factory/). The package__init__ended with_processor.MapFactory = MapFactoryand_handlers.MapFactory = MapFactoryΓÇö the package's only cross-module attribute assignment, against a no-side-effects-on-import rule ΓÇö while both submodules declaredMapFactory = Noneand called it as a global. Reloading either on its own put theNoneback, and every primitive call then raisedAttributeError: 'NoneType' object has no attribute .... Correction to the standing description of this: it was said to reproduce underModuleReloader(include_submodules=True), which is the path tentacle's Reload Tentacle package button takes withpythontkexplicitly in its list ΓÇö measured, and it does not: the topological sort re-runs the parent after its children at both pass counts, so a whole-package reload was always fine. What breaks is reloading one submodule alone. The defect is the side effect itself, plus correctness resting on reload order. Each module now resolves the name in_factory()at call time (27 call sites). A module-level__getattr__looks like it would do this with no call-site change and does not: PEP 562 coversmodule.attrfrom outside only, and a bare global lookup inside the module raisesNameErrorΓÇö the attempt was caught byruffF821 on all 27, not at runtime._factoryis also the DI seam, and the handlers test that patched the injected global is re-pointed in this commit: that name is now absent, sopatch.object(module, "MapFactory", ...)raises rather than guarding nothing, which a new test pins. The re-pointed test also asserts the stub was actually called ΓÇö without that it passed just as happily when the patch missed and the real primitive succeeded. Separately,ConversionRegistry._scan_pendingpreferredcls.register_conversions(registry)and fell back toregister_from_class, which scans members for a_conversion_infoattribute that nothing in any of the seven packages sets ΓÇö there is no decorator producing it ΓÇö so the fallback measured zero invocations while five real conversions resolved. Its only practical effect was to make a plugin that forgotregister_conversionsa silent no-op;add_pluginnow refuses one, andregister_from_classis deprecated for removal next release alongside item 12's cluster, rather than deleted under a second break in the same tier. Published surface unchanged. -
2026-09-04 ΓÇö a cancelled texture batch now actually stops (
core_utils/engines/textures/map_factory/_map_factory.py).prepare_mapshad no cooperative checkpoint in either batch branch, so the cancel plumbing that exists end to end ΓÇöexecution_monitorsets the flag, uitk's dispatcher catchesOperationCancelledΓÇö terminated in a loop that never asked. Reproduced directly: a scope cancelled before the call still processed all six sets and returned a full result.OperationCancelledalready derives fromBaseExceptionprecisely so it slips past the broadexcept Exceptionhandlers this loop is built from; nothing was raising it. This is the branch that runs in production ΓÇö mayatk's material updater is@Cancelable(300), and every live caller (bothmat_updatersites, both extapps converter sites,game_shader) leavesmax_workersat its default of 1, taking the serial path. New_checkpointfires up front, so an already-cancelled scope never spins up a pool, and between sets in both branches. The parallel branch checks on the collecting thread and cancels what is still queued: the ambient scope is aContextVar, and aThreadPoolExecutorworker starts with an empty context ΓÇö measured, not assumed ΓÇö so a checkpoint inside the submitted task would be a silent no-op, and the executor's__exit__waits for shutdown and would otherwise drain the whole remaining queue anyway.progress_callbackreturningFalsenow cancels too, the progress-barupdate()contractCancelScope.tickis written to match and thatCancelabledocuments as the free checkpoint; its return value was being discarded.Noneis deliberately not a cancel, since a callback that only prints returns it ΓÇö pinned by its own test, as is the no-op case with no scope active. Cancelling raises rather than returning the sets finished so far, so a caller cannot wire a half-finished batch into a material as complete. Public surface unchanged (406 names). -
2026-09-04 ΓÇö one ffmpeg resolver, and an auto-install failure that says what failed (
vid_utils/_vid_utils.py,audio_utils/_audio_utils.py).VidUtils.resolve_ffmpegandAudioUtils.resolve_ffmpegwere statement-for-statement identical apart from a single error string ΓÇö measured, not eyeballed: stripping docstrings left exactly one differing line. Only one of them is reachable asptk.resolve_ffmpeg, becausevid_utilsis a wildcard root, so a fix applied to the bare name silently missed the audio copy. The audio one now delegates; both names stay published because their callers differ (the audio side passesauto_install=True). Separately, both swallowedAppInstaller.ensurewithexcept Exception: passΓÇö andensureraises on a checksum mismatch and a truncated download as well as on a plain network failure, so every one of those reached the user as "FFmpeg is required but not found in the system path", sending them to look at their PATH when the real answer was a corrupted or tampered payload. The failure is now logged and named in the raisedFileNotFoundError. Guarded by routing (a mock onVidUtils.resolve_ffmpegcatches the audio call) rather than by comparing source text, plus a shape check that the audio body holds no second implementation.shutilbecame unused inaudio_utilsand was removed. -
2026-09-04 ΓÇö one clip-timing source of truth: four passes in one session resolved fps three different ways (
file_utils/mesh_convert/_mesh_convert.py)._resolve_clip_fpsis the published rule, and its docstring names the shot system as authoritative — but only three passes called it, and none implemented the full precedence. The clips and fades passes carried byte-identical inline copies of "shot_metadata, else the visibility channel", coupled to each other only by a comment.apply_glb_animationsdid "shot_metadata, else derive from clip spans" and never read the channel at all, so a file whose only stated rate lives there fell through to derivation while its neighbours used the stated number. Andapply_glb_visibilityinverted it — channel first,shot_metadataonly as a fallback, no derivation. Those channels are written by different producers and can disagree, so the four passes placed their keys at times that did not match one another, in a converter whose output ships. The rule is now stated once —shot_metadata.fps→visibility_tracks.fps→ clip-derived ratio — and all four callers go through it. This changes behaviour: a deliverable whoseshot_metadata.fpsandvisibility_tracks.fpsdisagree gets different visibility key times than before, and the shot system's rate is the one that wins. Pinned by a disagreement test (shot_metadata24 against a track channel at 30, where frames 8→23 are 0.625 s rather than 0.5 s), plus the two fallbacks that must survive it: the channel is still used when the shot system is silent, and a published0is not a rate and falls through rather than dividing. -
2026-09-04 ΓÇö the text handler is per-logger; the process-wide default is now something you ask for (
core_utils/logging_mixin.py).set_text_handleris attached to every logger, soself.logger.set_text_handler(X)reads as configuring that logger ΓÇö and it assignedLoggerExt._text_handler, a class attribute, because it sat in thedirect_methodsmap, the one for "methods that don't need self". Bound without a logger it could only write a global. Reproduced directly:Alpha().logger.set_text_handler(X)changedBeta().logger.get_text_handler()for an entirely unrelated class. It had already cost a consumer ΓÇö extapps' compositor attaches its handler by hand with the comment "_set_text_handlerwould force this handler process-wide", bypassing the seam that exists for exactly that job. The two accessors move to the wrapped map so they receive their logger, and the handler is stashed on it. The global was load-bearing for four call sites, traced before shipping:blendertk'stelescope_rig/shader_templates/mat_updaterand extapps'substance_workflowcallsetup_logging_redirectwith no pairedset_text_handler, so whether their log pane got the Qt handler depended on which unrelated panel opened first ΓÇö a dependencyextapps/test/test_substance_workflow.pydocuments and calls a hazard. NewLoggerExt.set_default_text_handler()is the deliberate form of that: a host wanting one handler for the whole process asks once, and a per-logger handler still wins. The four panels are logged in.claude/BACKLOG.mdΓÇö they need a live-session check, which cannot be driven from here. Every call site that pairsset_text_handlerwithsetup_logging_redirecton the same logger (uitk's bridge slots and formPanel, mayatk's naming_slots and telescope_rig) is unaffected; each was checked individually. -
2026-09-04 ΓÇö
FileUtils.read_json/write_jsonandUserConfig.save_file: the halves the classes were missing (file_utils/_file_utils.py,core_utils/user_config.py, +4 call sites).atomic_write_textexisted and was composed at exactly two sites, while five JSON writers truncated their target and serialised into it, and tolerant reads were re-derived with at least six different catch sets ΓÇöjson.JSONDecodeErroralone (x4),ValueError(x3),(OSError, ValueError)(x2),FileNotFoundError,(json.decoder.JSONDecodeError, FileNotFoundError),(TypeError, ValueError). Those are not equivalent:JSONDecodeErrordoes not catch a missing file, so half the call sites raised where the other half fell back.read_json(path, default=None)catches both halves of "cannot be read";write_jsonserialises BEFORE touching the file ΓÇö the difference from the retiredset_json, which truncated first, so a valuejsoncould not encode destroyed the previous contents ΓÇö then writes throughatomic_write_text, creating parents.UserConfigpublished a loader and no writer, so every caller that needed to persist a config hand-rolled a non-atomic one;save_filerefuses a non-mapping, becauseload_filereads anything that is not a JSON object back as{}and the write would look like data loss later. Four of the five non-atomic writers are migrated (app_installercatalog,hierarchy_diff,qc_log, themetadatasidecar); the fifth is the deprecatedset_json, and a drift guard asserts by FILE that it is the only one left.QcLog.finalizehad no test of any kind and now has one. Correction to an earlier read of this: themetadatasidecar'sopen(path, "w")carried noencoding=, which looks like a cp1252 bug on Windows and is not ΓÇöjson.dumpdefaults toensure_ascii=True, so the bytes were pure ASCII escapes either way, verified before the claim was made. The real fix there is truncation. The deprecation message added forset_json/get_jsonnow points at these primitives rather than a two-stepjson.dumps+atomic_write_textidiom. -
2026-09-04 ΓÇö
ptk.Gitand the JSON key-value store are deprecated, for removal next release (core_utils/git.py,file_utils/_file_utils.py). Both are published surface with no callers.Gitresolves, is exported, and has zero references in any of the seven ecosystem packages and no test file of its own. Theset_json_file/get_json_file/set_json/get_jsoncluster is reached only by pythontk's own tests, and it keeps a process-global path created on first write, validates with bareasserts that vanish underpython -O, and truncates the file before it serialises ΓÇö so a faileddumpsloses it ΓÇö while sitting ~800 lines belowatomic_write_text. Its real cost is navigational: those are the only JSON read/write names on the public index, so the documented "checkAPI_INDEX.mdfirst" workflow routes the next author straight into the trap. The warnings live in the bodies because a module-level__getattr__cannot intercept either ΓÇöGitis registered by name through the package resolver, and the JSON four are classmethods on a wildcard-exported class. Behaviour is unchanged this release and pinned by a test, so nothing breaks yet; the six existing tests that exercise the cluster deliberately now filter the warning by message, so a genuinely new deprecation still surfaces. A further test asserts the names stay marked deprecated until deletion ΓÇö the retirement plan's hard constraint is thatget_json/set_jsonmust never be reused for a later tolerant-JSON primitive, or a caller pinned to an old release would silently get the non-atomic implementation under a name that now means something else. The published surface is unchanged (404 names), as the snapshot gate confirms. -
2026-09-04 ΓÇö the shadow bake's peak memory is now bounded by construction (
geo_utils/shadow_horizon.py)._marchvectorises every (texel, bin) ray against every sample at once, so one call allocates about a dozen(chunk * bins, samples)arrays ΓÇö but the chunk was a fixed 1024 texels, which bounds none of that: the working set ischunk x bins x samples, so raising either parameter multiplied memory silently, andThreadPoolExecutor's default ran up tomin(32, cpu + 4)marches at once. Measured on a 20-core box, one chair bake at the defaults peaked at 4306 MiB RSS, and a full test run died inside this function withnumpy._core._exceptions._ArrayMemoryError: Unable to allocate 64.0 MiBΓÇö in a library whose callers run it inside Maya and Blender beside a loaded scene. The chunk is now derived from aRAY_BUDGETof 2^20 elements (_solve_chunk_size, 128 texels at the defaults, halving wheneverbinsorsamplesdoubles), and the default worker count is capped atMAX_BAKE_THREADS = 8ΓÇö measured 5.09 s at 8 against 5.36 s at 24, so the extra threads bought only footprint. Re-measured after the change: 616-762 MiB over three runs at 5.52-5.81 s, against 4306 MiB at 6.01 s ΓÇö about 6x less memory and no slower, the smaller arrays staying closer to cache. An explicitthreads=still wins, andthreads=1still selects the serial path. The tests assert the invariant (the chunk shrinking asbinsandsamplesgrow, never to zero) rather than a megabyte figure, which would be flaky. -
2026-09-04 ΓÇö the in-application RPC server could bind over a LIVE one on Windows (
net_utils/rpc/plugin_core.py)._ReusableServersetallow_reuse_address = Trueunconditionally, with a docstring giving only the POSIX rationale (reuse aTIME_WAITport so a host relaunch is not blocked). On WindowsSO_REUSEADDRadditionally permits binding over a live listener, and this is the server that pins a port inside Toolbag and Painter — so a second plugin instance started cleanly beside a stale one and the stack decided which socket received each request, leaving a client driving a dead session while everything looked healthy. Demonstrated directly: with the old setting a second_ReusableServerbound the same live port and both ran; withallow_reuse_address = os.name != "nt"the second bind is refused with WinError 10048, and a rebind after a clean close still succeeds, so a host relaunch is unaffected. This is the policypreview/server.pyalready reasoned out for the identical pinned-port-inside-a-DCC shape; the two are now asserted together bytest_plugin_core.TestBindPolicyso they cannot drift apart again. The four vendored_rpc_core.pypayloads (mayatk / blendertk × marmoset / substance) are re-synced bym3trik/scripts/sync_rpc_core.py. -
2026-09-04 ΓÇö
motion_spancould not see motion that accumulates below its tolerance (file_utils/mesh_convert/glb_reader.py). It compared each key only against the one before it, so a channel drifting by less than tolerance per key read as perfectly static however far it travelled. Measured: 400 keys stepping 0.0005 each ΓÇö half the 1e-3 default ΓÇö move 0.2 m over 13 seconds and the method returnedNone, while the same 0.2 m delivered as a single jump between two keys was detected. The gate's answer depended on how the motion was distributed rather than on whether it happened, andcheck_clips_vs_takesFAILs on motion past the last take while only WARNing on an inert margin ΓÇö so a slow drift out there was reported as a held pose and shipped. New_moving_spantrims the leading and trailing HOLDS instead: the run of keys still at the opening pose and the run already at the closing pose. That has no blind spot for distributed motion, still brackets a discrete late jump to exactly the two keys it spans, and still returnsNonefor a genuinely static channel ΓÇö all three re-measured after the change. -
2026-09-04 ΓÇö
RemoteFiledid not enforce thehttp(s)contract it documents (net_utils/remote_file.py).urlopenspeaksfile:,ftp:anddata:natively, and nothing on the fetch path consultedis_urlΓÇö which already rejected those schemes and was already tested doing so.RemoteFile.read_bytes("file:///...")therefore returned the bytes of a local path, from a class whose first docstring line is "Read a file byhttp(s)URL" and which publishes asptk.RemoteFile. Every caller today gates onis_urlfirst (uitk's line-edit validator, both shot-manifest slot modules), so this was a contract hole rather than a live exploit ΓÇö but the next caller reads the docstring, not the call sites. The check now lives inopen(), the single choke point every fetch passes through. Separately,read_bytesandprobenormalize and then hand the result toopen, which normalizes again; that is harmless only whilenormalizeis idempotent, which nothing asserted ΓÇö now pinned across all six rewrite shapes. -
2026-09-04 ΓÇö a wedged
taskkillcould hang the watchdog forever (core_utils/execution_monitor/_sidecar.py).kill_processshelled out totaskkill /Fthroughsubprocess.runwith no timeout, andrun_watchdogreturns as soon as that call does ΓÇö so a target stuck in a driver call, or a wedged WMI, left the watchdog process alive indefinitely. That is precisely the failure the sidecar exists to prevent, occurring inside the sidecar. Bounded by a namedKILL_TIMEOUTof 15 s: generous for a real kill of a deep process tree, short enough that a wedged one cannot outlive the run that spawned it. On timeoutsubprocess.runkills thetaskkillchild and raises, which the existing best-effortexceptabsorbs ΓÇö the target may survive, but a kill that gave up beats a watchdog that hangs. -
2026-09-04 ΓÇö the shadow bake's wall-clock budget is re-based on measurement (
test/test_shadow_horizon.py). The chair-at-defaults budget was 12.0 s against a docstring note of "about two, measured". Re-measured warm and uncontended over six consecutive runs: 6.94 / 7.31 / 8.02 / 8.28 / 8.30 / 8.75 s, median 8.15 ΓÇö ~1.5x headroom, not the ~6x the note implies. The test therefore failed inside a full-suite run at 17.1 s and passed alone, which is a wall-clock assertion reporting machine load as a code defect. Raised to 30 s, which still catches anything ~4x slower, with the measured distribution written into the docstring so the next reader is not misled by the stale figure. The gap between "about two" and 8.15 is deliberately NOT resolved here ΓÇöshadow_horizon.pyarrived in one commit so there is nothing to bisect, and only whoever took the original measurement can say whether the bake regressed or the note came from different hardware. Logged in.claude/BACKLOG.md; the docstring points there. -
2026-09-04 ΓÇö the published surface is pinned to a checked-in snapshot (
test/surface_snapshot.json,test/test_surface_snapshot.py). pythontk is the bottom of the chain and every exported name is a contract, but nothing watched membership: the registry gate hashes generated documents and so reports a diff rather than failing on a removal, and the namespace-alias tests check mechanics, not membership. Nothing at all watched where a name resolves, so moving a class between modules ΓÇö exactly what a base-class extraction does ΓÇö changed the public surface silently while every gate stayed green. The snapshot pins name, tier, defining module and qualname for all 404 published names (192 root, 212 flat aliases), so a rename, a removal, a retier and a cross-module move each surface as a named diff; both directions were verified by mutating the snapshot. It is not a freeze ΓÇöpython test/test_surface_snapshot.py --updateregenerates it ΓÇö the point is that the change has to be deliberate. Six names that consumers already reached by raw module path are now registered rather than left as undeclared dependencies on a private path:LoggerExtandLevelAwareFormatter(uitk's example wiring, mayatk'sscene_audit), andScriptTemplatewithSEND_TO/SAVE_AS/ROUND_TRIP(mayatk'sblender_bridgeimports all four straight from the module).GlbClipsandGlbFadeswere proposed alongside them and are NOT added ΓÇö measured, they have zero consumers in any of the seven packages, and publishing surface nobody calls is the thing this plan elsewhere removes. -
2026-09-04 ΓÇö the browser-leak check now gates the run CI actually performs (
test/conftest.py).run_tests.pyhas always readTestSandbox.launchesand exited 1 on a leak, but.github/workflows/tests.ymlruns barepytest test/and never loads that runner ΓÇö so on the path that gates a PR, five downstream suites armed the guard and nothing read the record. A refused launch swallowed by a broadexceptleft its test green; the same call opens a tab in production. Apytest_sessionfinishhook setssession.exitstatus(a print gates nothing, which is the mistake it replaces), verified by running pytest in a subprocess over a synthetic test that swallows a blocked launch: the test passes, the run now fails. Alongside it, a collector drift guard asserts pytest's collected count equals unittest's discovered count ΓÇö the two collectors are blind to different things (a module-leveldef test_x()is invisible to unittest discovery), and either way the tests silently never run. Proven by planting a pytest-only function: 4170 vs 4171, caught. It compares whole-directory runs only, so a targeted run skips instead of failing. -
2026-09-04 ΓÇö four shipping gates that were prose-only (
.github/workflows/,test/test_packaging_metadata.py). (1)publish.ymlbuilt without clearingdist/ build/ *.egg-info, so a stalebuild/libcould ship a file the tree no longer has, and it installed the wheel without ever importing it ΓÇö it now cleans first and imports the installed wheel from a directory with no pythontk source in it, asserting the four__file__-relative runtime data files arrived. (2) A pinnedruff checkjob ΓÇö lint only, deliberately notruff format --check: 114 files predate the current ruff and a format gate would be red on arrival and therefore ignored. (3) The sdist and the wheel are declared by two independent lists that had already drifted ΓÇö*.md/*.txtinpackage-databut notMANIFEST.in,*.pngthe other way ΓÇö sopythontk/core_utils/README.mdand its two siblings shipped in the wheel and not the sdist. Both lists are unified and a test asserts set-equality plus full coverage of every non-.pyfile, verified by dropping a pattern. (4) The no-DCC rule was guarded by a substring search over one module of 142, Qt-only, which read its own comments as violations and would have missedimport mayawith two spaces. It is replaced by an AST walk over all 142 with two rules matching the two hazards: a DCC module is forbidden at any scope (today zero modules import one), a Qt binding only at module level ΓÇö leavingnet_utils/rpc/plugin_core._qtcore's deliberate lazy resolution valid, and needing no allowlist. Both rules verified against planted imports. -
2026-09-04 ΓÇö
listify(threading=True)was a loss on every method that used it but one (core_utils/_core_utils.pycallers infile_utils,math_utils,str_utils). The decorator builds aThreadPoolExecutorper multi-item call, and the flag was set on nine methods that are microsecond-scale pure Python. Measured on 2/200/2000-item lists, threading cost 2.4x to 173x ΓÇö worst at the small sizes real callers pass:set_case173x slower on a two-item list,clamp98x,truncate152x,split_delimited_string158x,format_path49x,move_decimal_point35x,convert_to_relative_path18x.time_stampwas measured too rather than assumed I/O-bound ΓÇö itsos.path.getmtimeis a stat call, and it was still 1.4x to 9.7x slower, so it comes off with the rest. Eight decorations lose the flag;ImgUtils.create_maskKEEPS it, because it is the one that measured faster ΓÇö numpy/PIL release the GIL and it ran at 0.56x / 0.25x / 0.21x the serial time on 2/20/100 1024px images, so a well-meaning sweep must not strip it, and a test now pins that split in both directions.CoreUtils.listify'sthreadingparameter is unchanged and still published. One semantic delta worth knowing:executor.mapsubmits every item eagerly, so a raising element used to leave the rest already dispatched; the comprehension short-circuits at the first failure. -
2026-09-04 ΓÇö
Palettewrapped on two write paths out of seven (core_utils/color.py).Palette(dict)overrode__init__and__setitem__, soupdate,setdefaultand|=stored raw strings, whilecopyand|returned a plaindictΓÇö which then silently stopped wrapping everything written to it afterwards. A Palette holding a rawstris indistinguishable from a correct one until something indexes it as aColor, and the failure surfaces far from the write. All five are now overridden (plus__ror__, so a plain dict on the left still yields a Palette),setdefaultreturns the wrapped value rather than the raw argument, and the class base staysdictbecause it is published verbatim and dropping it would breakisinstancefor external consumers.fromkeysneeded no override ΓÇödict.fromkeyson a subclass already routes through__setitem__ΓÇö and a test pins that so a later change does not quietly break it. -
2026-09-04 ΓÇö one non-weakref-able value corrupted an entire
NamespaceHandler(core_utils/namespace_handler.py). Three write paths ΓÇö__setattr__,__setitem__, and the resolver cache ΓÇö each carried a byte-identicalexcept TypeError:branch that wrote the bare value intoWeakValueDictionary.data, the mapping's private backing store. Every read in that container calls what it finds there, so a singlehandler.label = "text"made lookup,in,keys()and iteration all raiseTypeError: 'str' object is not callableΓÇö not one bad key, the whole namespace.str,int,float,tuple,boolandbytesall take that path; a namespace of settings is full of them. The three copies fold into one_store(), which stores a_StrongRefΓÇö a callable returning the referent ΓÇö so the container's contract stays intact and weak-referenceable values stay weak (pinned by a test that drops a value and collects). Known divergence left alone and pinned instead: a storedNonereads as absent underuse_weakref=True, becauseWeakValueDictionarycannot tell aNonevalue from a dead referent, whileuse_weakref=Falseround-trips it. The module already readsNoneas absent elsewhere; reconciling the two modes is a maintainer call. -
2026-09-04 ΓÇö a normal-map test reported green with its fixture deleted (
test/test_map_factory.py). The one assertion in the suite that reads a real bake rather than a generated hemisphere was wrapped inif os.path.exists(asset):, against the only reference totest/test_assets/anywhere ΓÇö so the fixture could vanish and the test would still pass. Verified by hiding the file: green before, red after. The guard is replaced by an explicit existence assertion, and the fixture is not repointed at the other normal map ΓÇö the two were measured as different images (4096 RGBA vs 1024 RGB) and the 0.15 threshold is calibrated to this copy. -
2026-09-04 ΓÇö the whole-timeline clip is judged on the frames it ANIMATES, not the frames it occupies (
file_utils/mesh_convert/export_verify.py,glb_reader.py).check_clips_vs_takescompared the full-sequence clip's key extent against the takes' end and failed on any difference, but a bake writes keys across whatever range it is handed, so that clip routinely ends on a held pose past the last take. Measured on the VDATS assembly: the exporter set the bake range correctly (33-3387, the shot union) and the clip still spans f0-3435 ΓÇö of the 48-frame tail, 1109 of 1185 channels are flat and the other 76 move by at most 3e-4, against a body moving 1183 of them by up to 3.5. A gate that is red on a correct file is a gate nobody reads, and the next real clip/take mismatch would have shipped behind it. The two ends now fail for different reasons, because they mean different things: ending SHORT is still a FAIL and is measured on KEYS (a clip whose keys stop early cannot play the declared range at all), while running long is measured on MOTION ΓÇö an inert margin is a WARN naming the frame count, and only motion past the last take FAILs, since that is content no shot will ever play. Motion stopping EARLY is never a fault: shots finish their action and hold for a beat, and the same assembly stops moving 79 frames before its last take ends ΓÇö judging on where motion stops would have called every one of those truncated, which is the mistake the first cut of this fix made and the production file caught. NewGlbReader.motion_span(key, tolerance)does the measuring: it walks each sampler's decoded outputs and reports the span between the first and last key where something changes,Nonewhen nothing does, reading the middle element of a CUBICSPLINE triple rather than its tangents. The default 1e-3 tolerance is below visibility on every animated glTF path ΓÇö 1 mm of translation, 0.1% of scale, ~0.11 degrees of quaternion rotation ΓÇö so bake residue lands under it and real motion does not. It decodes BIN for one named clip, and the gate only calls it once the cheap extent test has already flagged something. This is the distinction mayatk's shot sequencer already draws incollect_object_segments("drops hold spans so a clip reads as the animation it plays"), where conflating the two caused its own trailing-space bug. Two sibling tests asserted on the message wording rather than the finding's status ΓÇö one of them vacuously ΓÇö and now assert the status.test_export_verify.py36/36. -
2026-09-04 ΓÇö
FileUtilscan tell whether a file is held open, and by what (file_utils/_file_utils.py). Newis_locked,locking_processesanddescribe_lock, in the familyfree_spaceandexceeds_path_lengthalready form: turn an opaque write failure into a cause a caller can CHECK, before the work rather than at the write that discards it. Windows refuses to delete a file or rename onto it while another process holds it withoutFILE_SHARE_DELETE/FILE_SHARE_WRITE, which is the whole of[WinError 32] used by another processΓÇö a viewer, an editor or a preview causes it just by having the file open.is_lockedprobes by opening for APPEND: the cheapest request needing the same write access as a replace, and the only one that touches no bytes (no truncation, no mtime change); a missing path and a directory both answer False, so callers need no guard.locking_processesnames the holder through the Restart Manager (rstrtmgr, no elevation), and is garnish only ΓÇö it returns empty off Windows, when the API is unavailable and whenever it declines to answer, so the verdict must always come fromis_locked.describe_lockis the sentence both produce together, so this failure is worded the same wherever it is reported, and it still reports a lock it cannot attribute rather than reading as free. Verified against a real lock (aFILE_SHARE_READhandle from another process): the probe agrees with both operations it gates ΓÇöos.removeandos.replaceeach raise there and succeed once released. Tests intest_file.py(9 cases, the lock-holding ones Windows-only), including one pinning that agreement so the check cannot drift into superstition. -
2026-09-04 ΓÇö
ImageCuratorno longer deletes the capture it is about to read (img_utils/image_curator.py). Withoutput_rootset to the source's parent andsuffix="", the output dir resolves onto the source dir itself and the stale-output purgermtree'd it. The scan runs first, so its records outlived the delete and everycopy2then failed against a source that was gone -- the capture was destroyed andcuratestill returned the out_dir, reporting success.ExposureEqualizer.equalize_directorieshas guarded this exact case since 2026-08 with anormcase/normpathcompare and a comment naming the hazard; the same guard now applies here, plus a per-file skip the sibling does not need -- copying a survivor onto itself raisesSameFileError, so porting the guard alone would have traded data loss for an error-spamming no-op. In that mode culled frames are deliberately left in place and the warning says so. Pinned bytest_curate_never_purges_the_source_dir, mirroring the equalizer's. -
2026-09-04 ΓÇö optional-PIL guards read the binding, not a stale bool (
img_utils/ktx2_encoder.py,img_utils/mask_generator.py). Blender imports pythontk at startup, beforeensure_image_deps()can provision Pillow, so these modules cacheImage = None. blendertk's_rebind_pil_globalsrepairs that binding afterwards -- but by its own docstring only for "a name the module itself set toNone", so the companionPIL_AVAILABLEbool stays False for the life of the session.Ktx2Encoder.encodegated itsisinstance(source, Image.Image)staging branch on that bool, so after a mid-session install a live PIL image fell through to the path branch and toktx was handedstr(source)-- an image repr where a file path belongs.MaskGenerator.is_availableand its operator-facingPIL=diagnostic read the same stale bool, reporting a usable Pillow as missing and returning no masks. Both now testImage is not None, which is the name the repair actually restores. The bools stay bound: they are part of the optional-dep idiom that helper enumerates across seven modules, and the regression tests set them False on purpose to pin that reading them is no longer load-bearing.test/test_mask_generator.pyis new -- the module shipped without a test file. -
2026-09-04 ΓÇö a JPG output profile no longer destroys packed alpha (
core_utils/engines/textures/map_factory/processor.py).save_mapescalated jpg -> png from a hand-written five-name list that omittedMetallic_Smoothness, whose registry definition declaresmode="RGBA",is_packed=Trueandchannels={"RGB": "Metallic", "A": "Smoothness"}-- URP reads smoothness out of that alpha -- so choosing JPG in a format combo wrote a.jpgthat reopens RGB and the smoothness was simply gone.ImgUtils.dropped_channelsnames this failure in its own docstring. The list is kept as a floor and joined by a registry-derived check (TextureProcessor._map_type_carries_alpha), alias-tolerant throughresolve_type_from_pathso authoring spellings still resolve (MaskMap->MSAO), and consulting bothchannelsandmodebecause neither alone is complete:Metallic_Smoothnessdeclares its mode RGBA whileMSAOnames its alpha only inchannels. Deriving the rule instead of joining it would have silently droppedMRAOandORM, which are packed but measured with no alpha band. Enumerated after the change rather than assumed: the derived half adds exactly two types over the old list --Metallic_SmoothnessandEmissive_Mask(RGBA, unpacked) -- and both are correct and pinned in the test. -
2026-09-03 ΓÇö the task runner names the checks that failed (
core_utils/task_factory.py).run_tasksreturned a bare bool, so a consumer that wanted to report (or ask about) what failed had to re-derive it from the log._last_failed_checksnow carries the names beside the existing_last_task_count/_last_check_countstamps, rewritten every run so a passing run clears the previous one's list._last_skipped_tasksrecords the tasks the abort dropped, in schedule order, so a caller that decides to proceed anyway can run exactly those rather than the whole list again ΓÇö the ones above the failed check already ran, and repeating them repeats their mutation. Tests intest_task_factory.py. -
2026-09-03 ΓÇö
discover_dirgap-fill now scans the tree, not just the top folder (core_utils/engines/textures/map_factory/_map_factory.py).MapFactory._supplement_sets_from_dirlisted the scan directory non-recursively, but every caller that opts into discovery hands over a texture ROOT ΓÇö mayatk'sMatUpdaterpasses the project'ssourceImagesrule ΓÇö and that root is routinely one folder per asset. In that (common) layout the scan matched nothing and discovery silently no-opped. Measured on a production set: a StingrayPBS material wired to a Base_Color / Normal_OpenGL / Emissive trio plus a DEAD..._ORM.pnglink asked for thePBR Metallic/Roughnesspreset and kept its ORM wiring, because the looseMetallic,RoughnessandMixed_AOit needed sat one level down insourceimages/OFFICE_ENV/and were never seen; they are now discovered andGameShader._wireclears the packed connections offTEX_metallic_map/TEX_roughness_map/TEX_ao_mapas it binds them. Matching is unchanged (base name + map type, provided files always win), so depth widens the search without widening what can match.test_supplement_finds_siblings_in_a_subdirectorypins it. -
2026-09-03 ΓÇö One shader body for every consumer, and the documented pin made real (
geo_utils/shadow_horizon.glsl,ShadowHorizon.shader_source). The coverage-aware horizon evaluation existed three times — the numpy reference, the viewer's GLSL and Unity's HLSL — and they disagreed semantically, not just syntactically: the GLSL inspected three sub-bins where the reference walks a whole three-bin window (any penumbra wider than that was clipped), it testede + rho <= 0for "below the horizon" where the reference testse <= 0, it readGat run 0 without the reference'sG > 0guard, and it had no counterpart to the HLSL's second-run blend at all. Neither engine projected the fragment onto the ground plane before formingL, so both measured against a plane oneGROUND_OFFSETbelow the one the map was baked on. The algorithm is now one text:shadow_horizon.glsl, GLSL-authored with an#ifdef SH_HLSLprologue, written term for term againstHorizonMap.alpha. A consumer that IS a Python process when it needs the shader assembles it throughShadowHorizon.shader_source(language)(Maya, Blender); one that is not carries a mirrorm3trik/scripts/sync_shadow_shaders.pysplices between markers and--checkguards (the viewer, Unity) — CODE_STANDARD §6's sanctioned duplicate, invoked exactly twice rather than four times. Three things stay per-engine because they cannot be anything else:SH_Fetch(col, row, xi, yi)(the body computes a grid cell, never a texture address — glTF's top-left origin and Unity's two row flips are irreducible), the uniform block, and the language prologue. The frame now travels as an origin and three world-space axes instead of a world-to-contact matrix, so every binding is avec3— what a Maya.ogsfxuniform, a Blender push constant and a Unity instanced property are all proven to carry — andsource.wpicks a position or the direction the source shines, withsourceSizecarrying full widths halved once, in the shader.test_shadow_web.py's hand-written analytic pole answer is no longer the oracle: the page is now compared againstHorizonMap.alphadecoding the very bytes it samples, across 48 ground points (22 in shadow, 18 in the penumbra) with a worst disagreement of 0.0138; the analytic answer is kept as a second assertion so a wrong reference cannot pass by agreement alone. A directional source is now rendered against the reference too — 54 points, 34 in shadow, 29 in the penumbra, worst 0.0189. Nothing had rendered that path: the viewer's directional coverage was the projected rig's placement and Unity's horizon tests are all positional, so thew = 0uniform the shader negates had no check at all and a sign error there is invisible to every positional test. Its fixture has to sit OFF a bin centre — a directional source gives every fragment the samesat once, so a centred bearing putssat exactly 0.5, which is a dead tie insideand a boundary infloor(s * 16)decided by whether the GPU's float32atan2lands a hair either side of numpy's float64 (measured: 0.52 disagreement centred, 0.019 one third of a bin off). The shared body also fetches the bin on the far side of the light ONLY for a source disc wide enough to spill past a bin edge: a point source's sub-bin is always inside bin k, so the third tile was 8 texel loads per fragment that nothing read — the contract's stated budget of sixteen loads for a point source and twenty-four for a disc is now what the code does, and both engines re-measured identically after the change. Two portability traps cost a compile cycle each and are now pinned bytest_sync_shadow_shaders.py: a backtick anywhere in the body is a JS syntax error in the viewer's template literal (the browser then refuses the whole module, with the shader nowhere in the message), andpointis a reserved word in HLSL. -
2026-09-03 ΓÇö
HorizonMap: three tap-blending corrections, so the reference is worth pinning shaders to (geo_utils/shadow_horizon.py).HorizonMap.alphais documented as the oracle for both engine shaders, but it was wrong in three places the shaders had each worked around — or tripped over — independently, so no two of the three agreed. (1) The grounded later-run top now blends only over taps that HAVE a second run.Gis the second run's top and is written0on a one-run texel, so blending it over every covered tap let a one-run neighbour drag the top toward the zenith and lengthen the shadow — the exact failure_interval's own docstring names, applied to the wrong predicate._intervaltakes an optionalcoveredpredicate, and a new_run_startssupplies it — one primitive for both run questions the grounded layer asks (.sum(-1)counts the runs for the blend,cumsum(-1)indexes them for the light's sub-bin), where the count and the index had been two separate inline transcriptions. (2)tapsbreaks a weight tie toward the higher texel, as the shaders do, instead ofargmax's lowest index: the nearest tap alone decides the grounded run index, so at a dead tie the old rule selected a different branch, not just a rounded value. (3) The layer's early-out is the coverage, not the nearest tap's mask —cov_phialready sums over all four taps and binsk ± 1, so gating on the nearest zeroed a texel its neighbours cover, against the contract's own rule ("an all-zero mask at every tap → 0"). Measured on the fixtures before and after (samples = 12,size = 192, one-texel-tolerant): box 1.52 → 1.51 %, table 2.91 → 2.81 %, chair 5.59 → 5.28 % — every fixture improved, the chair most, which is the signature you would expect since it has the most multi-run bins.test_shadow_horizon.TestTapBlendingpins all three on hand-built maps (a baked fixture cannot put four taps into a chosen disagreement); the design doc's shader pseudo-code carried the broken run form and is corrected with them. -
2026-09-03 ΓÇö the WebXR viewer awaits its scripts before loading the model (
net_utils/preview/viewer.html). A push names the asset and the active scripts together, and the page imported the modules without awaiting them: a small GLB parses in less time than an ES module takes to arrive (measured on KB-scale fixtures), so a shim registered forloadcould miss the only one it would ever get and leave the scene unshimmed. Oneawait; a module that throws still lets the model load, sinceloadScriptscatches per module. -
2026-09-03 ΓÇö
ShadowHorizon/HorizonMap: a ground shadow an engine can follow a runtime light with (geo_utils/shadow_horizon.py,img_utils/_img_utils.py). The bake half of the shadow-rig contract (mayatk/docs/shadow_rig_morphing.md). Per ground texel, azimuth bin and layer the map stores the elevation interval the occluder blocks plus a 16-bit occupancy mask over the bin's 16 sub-bins, so a chair leg casts ONE shadow on the light's true bearing: a plain horizon map crossfades neighbouring bins into two ghosts, and an azimuth hull merges two legs whenever they share a bin (measured ΓÇö at four metres that happens even with 64 bins). Two layers, grounded and floating, because a table top spans a whole bin while its legs are thin; one interval per bin measured 15-20 % disagreement against the exact projection on furniture. Intervals are stored ascot(angle) / max_stretch: a shadow boundary moves as the cotangent, so 8-bit degrees put a grazing overhang's edge tens of centimetres off. Texels are log-polar around the contact (an eighth of the footprint radius to the reach cap ΓÇö a leg's shadow crosses the ground UNDER the object, and half the radius lost half a pole's shadow) in the target's own frame, so a prop moved at runtime carries its shadow and one bake serves any number of sources.HorizonMap.alphais the numpy reference both engine shaders are pinned to;ShadowHorizon.measurescores it againstImgUtils.rasterize_shadowat random sources andbake_adaptivedoubles the bins until the tolerant score passes. Defaults measured, not guessed: 32 bins, 256 x 64 tiles, a 128-pixel footprint ΓÇö box 1.2 %, table 3.1 %, chair 5.3 % at 4 MB and about three seconds. NewImgUtils.rasterize_height_fields(top/bottom z-buffers with edge splatting, so a member thinner than a pixel still registers) feeds it. -
2026-09-03 ΓÇö
ShadowAtlas: equal-cell packing for shadow tiles, a tile rewritten in place (img_utils/shadow_atlas.py). The lightmap baker packs maps of varying size by area; a shadow tile is a fixed square (the projected rig's canvas is absorbed by the plane's scale, a horizon block is a fixed grid), so a plain grid is both optimal and stable ΓÇö adding a rig appends a cell and recalculating one rewrites its texels without moving anyone else's rect. Rects follow the lightmap convention (scaleX, scaleY, offsetX, offsetY, bottom-left) through the same inset/snap helpers, so an engine applies them exactly as a lightmap'sscaleOffset. Pure numpy: the twin without PIL composites with it too. -
2026-09-03 ΓÇö the shadow rigs reach the WebXR preview:
MeshConvert.apply_glb_shadowsbinds the planes' maps and publishesextras.shadow_web, and the packagedshadow_rigviewer script drives them (file_utils/mesh_convert/_mesh_convert.py,net_utils/preview/server.py,net_utils/preview/scripts/shadow_rig.js). The GLB half of the shadow-rig contract (mayatk/docs/shadow_rig_morphing.md, Contracts). The pass readsshadow_metadata(v1 or v2; a v1 record is filled to a still projected plane) off thedata_exportcarrier, resolves each plane, source and contact by name to glTF node indices (exact, then every namespace-stripped leaf match — one entry per plane node; an ambiguous source leaves the plane unfollowed), embeds the loose horizon PNG byte for byte under a LINEAR / no-mip / CLAMP_TO_EDGE sampler (a silhouette the material lost likewise, under the atlas clamp sampler), records the material and texture indices, flips the atlas and horizon rects to glTF top-left, and replaces the root manifest on every run. Runs infbx_to_glbright after the lightmaps, with a newshadow_dirs=searched ahead of the FBX's own directory, asourceimagesbeside or inside it and the lightmap dirs.optimize_glb_textureskeeps every image the manifest binds AS FOUND (the horizon map is data: a lossy encode corrupts it, and a lossless one may rewrite alpha-0 texels).PreviewServer.AUTO_SCRIPTSturns the script on by itself —publish()probes a GLB's JSON chunk and activatesshadow_rigfor one carryingshadow_web, appended to whatever the push named — andSCRIPTSregisters it. The script gives every plane oneShaderMaterial(projected silhouettes and horizon maps in one program,uMode), batches the projected planes that share a colour map and carry no fade track into anInstancedMeshwith per-instance rect and opacity/intensity, keeps a faded plane on its own mesh with the page's pointer ramp still driving it, and re-places every following plane per frame from a port ofShadowProjection.model/ShadowModel.placementin model space (rotateY = atan2(ux, uz), scale by the quad's own size, liftedGROUND_OFFSETDCC units — the Maya expression, term for term). Horizon planes decode their map from its own bytes with premultiplication off (the loader's decode quantises the occupancy bits in low-alpha texels),texelFetchthe four taps around the log-polar sample, blend intervals over covered taps in cotangent space with the light-side neighbour bin, keep a second grounded run on its own channel, and combine the two layers. Measured and pinned (Maya 2025 + FBX2glTF 0.13.1): a directional light arrives with its shining direction on the glTF node's local -Y — the FBX light pre-rotation, baked into the node — while a locator's frame passes through unchanged; that is the axis a directional source is read from (FBX2glTF also tags the nodeKHR_lights_punctual, whose own convention is -Z; the page's lights are its own).test_shadow_web.py28/28: 21 pure (manifest shape and indices, top-left rects, the data sampler, a byte-identical embed, idempotence, the v1 fallback, refusal of a newer schema, a missing map, all-leaf matches with an ambiguous source, the optimiser's keep, thefbx_to_glbregistration with its default search dirs, server auto-activation) and 7 in headless Edge over the real page: a placement equal to the Python model to 1e-5, a moved source re-placing the plane at runtime from a centimetre record, the directional axis on an asymmetric rotation, a two-planeInstancedMeshbeside a faded mesh whose opacity follows the ramp (0.5 after a seek to 1 s), and a horizon plane read back through an offscreen target against an analytic pole map (1.0 in the shadow; 0 past the tip, off bearing and pastr_max). Two defects the first real Maya export found, both fixed and pinned: the pass recorded each plane's OWN silhouette PNG as its colour map whenever the material carried one or none (a Maya standardSurface loses its file texture through the FBX hop, so on a real export every plane's material arrives bare), which left the atlas rect describing a tile of an image the plane was not sampling and gave the three planes three different texture indices — it now prefers the record's atlas, resolved from the images the file already carries before any directory is searched, and falls back to the material's map (which IS the atlas once the base-colour sidecar has run) and then to the loose PNG; and the viewer batched on whether a plane's MATERIAL carried the map rather than on the texture the shader samples, so the real export drew three separate meshes where the whole point of an atlas is one draw call — the batch key is now the manifest'stexture_index, and a map no material points at is fetched through the parser. Measured on the production export (three rigs, one 256² atlas): oneInstancedMeshof three, each instance on its own tile, placement within 1.5e-8 of theShadowProjectionreference; the atlas rects were verified against real pixels (cropping the atlas at the published top-left rect matches each plane's own PNG at mean |ΔA| ≈ 9/255, against 25–135 at the unflipped one). The horizon decode also takes its cotangent scale fromhorizon.max_stretch— the scale the map was BAKED at — falling back to the record's top-levelmax_stretchfor an older map; the top-level value stays the live placement cap, and decoding with it after an artist retunes the attribute doubled every shadow's length (measured: a point past the pole's tip read fully shadowed). One caveat of the page's loading order: scripts and the asset load concurrently and the firstloaddoes not wait for the imports, so a deliverable small enough to parse before the 40 KB module arrives shows still planes until the next push — the script says so in the console, and the live tests bring the page up before publishing for that reason. -
2026-09-02 ΓÇö
ShadowProjection: the canvas stamp pins a shadow's near edge to the footprint (geo_utils/shadow_projection.py). Reported through the DCC rigs, with screenshots: a grounded target's shadow sat a body-width away from its feet under a low source and attached only under an overhead one. The canvas was stamped as fractions of the model's LENGTH, so the whole texture slid along as the shadow grew (measured: a 2-unit box's near edge placed 0.75 off its face). The physics pins the near edge to the projected footprint ΓÇö ground points project onto themselves at any light height ΓÇö while only the far edge follows the top's projection.ShadowModelnow carries the projectedbaseandtopdisk radii (near/lengthstay as properties), and a stamp is the near edge in base radii from the anchor, the far edge in top radii from the head's centre, the sides as fractions of the width;rect/placement/fractionsstill invert each other, and a box's canvas is now exact at BOTH edges at any light height (test-pinned: stamped under a high light, re-placed under a low one; a floating target's near edge scales with its projected footprint). The stamped values change meaning, so a plane rasterized earlier today restamps on the next Recalculate Silhouette. -
2026-09-02 ΓÇö
ShadowProjection: planar shadow projection, pure numpy (geo_utils/shadow_projection.py), andImgUtils.rasterize_shadow(img_utils/_img_utils.py). The geometry under the mayatk / blendertkShadowRigtwins, placed here once so neither DCC carries it.ShadowProjection.projectmaps world points onto the ground plane along a source's rays ΓÇö a perspective projection through a positional source, a parallel one along a sun's direction ΓÇö and reports each point's penumbra spread (how much a finite-size source blurs that point's shadow, growing with its height off the ground).ShadowProjection.modelreduces an occluder to its bounding cylinder and returns the rectangle its shadow spans (ShadowModel: anchor, bearing, base/top projection factors, reach, near edge, length, width), every term a clamp or a ratio so a Maya expression or a Blender driver evaluates it live;fractions/ShadowModel.rect/placementexpress a rasterized canvas as fractions of that model, which is what re-places a plane at any light position.rasterize_shadowprojects world meshes through the source and fills the triangles where they land (an overhead source draws the footprint, a low sun the stretched shape, a near lamp the perspective-grown head), draws a source-size penumbra as a variable-radius blur (sharp at the contact, soft at the tip;_fill_trianglegains a max-composited value mode for the per-triangle sigma field), and returns theShadowRasterit drew into (model, canvas rect, fractions, widest penumbra). A caller passes the model'scontact/radius/heightso the canvas is measured in the frame its own expression places the plane in (the DCC twins pass their contact handle and stamped constants); a penumbra is capped at the reach, so a vertex level with the source cannot blow the canvas up. Rows run from the light-side edge at the top of the saved image; the across-bearing axis is defined identically for Y-up and Z-up, so one texture reads the same way in either DCC.rasterize_silhouette(the side-view rasterizer) is unchanged. Tests:test_shadow_projection(21: a sun's reach is height x cot(elevation), perspective growth, overhead footprint, the floating target's slide, the reach cap, canvas round-trips, the raster's orientation, penumbra widening, a given canvas, Z-up parity). -
2026-09-02 ΓÇö
PreviewBridge.push/publish_fileno longer overrule a deliverer'sopen_browser(net_utils/preview/bridge.py). Both defaulted to"auto", and that travelled as the request's EXPLICIT answer ΓÇö so aPreviewDeliverer(open_browser=False)still opened a real tab on every push whose caller said nothing. Measured: one tab per run of the preview suite, with every test green. The default is nowNoneΓÇö defer to the deliverer, whose own default stays"auto"ΓÇö so nothing changes for a caller that never configured one (mayatk/blendertkWebXrPreview, tentacle passes"auto"explicitly).test_push_defers_to_a_deliverer_that_says_no_browserpins it. -
2026-09-02 ΓÇö
TestSandbox: process-level test isolation for every suite in the stack (core_utils/test_sandbox.py).ptk.TestSandbox.activate()refuses everywebbrowserlaunch for the rest of the process ΓÇö loudly (aRuntimeErrornaming the fix) and recorded onlaunches, so a refusal the code under test swallowed still fails the run ΓÇö and routestempfile.gettempdir()plusTMPDIR/TEMP/TMPinto one throwaway root removed at exit (age-swept if the process is killed), so a run'sTempArtifactsstores, hand-off payloads and scratch dirs no longer pile up loose in the system temp dir (24,500 entries on one workstation; 1,371 from the preview suite alone). Activated from pythontk's and mayatk's conftest + runner;uitk.testing.TestSandboxnow extends it, so uitk/tentacle/extapps get it through the call they already make. A test that means to open a page patcheswebbrowser.openas before ΓÇömock.patchlayers over the guard.test_test_sandbox.py5/5. -
2026-09-02 ΓÇö
PreviewServer.stopreturns in ~50 ms instead of up to 500 (net_utils/preview/server.py).serve_foreverpolled at the stdlib's 0.5 s, andshutdownwaits for the next poll — most of a suite that starts and stops a server per case (155 cases: 74 s → 24 s).POLL_INTERVAL = 0.05, a class attribute. -
2026-09-02 ΓÇö a GLB no longer ships an empty animation (
file_utils/mesh_convert/_mesh_convert.py). NewMeshConvert.prune_glb_animationsdrops every animation with no channels or no samplers (glTF requires at least one of each, so the file did not validate) and runs in the conversion after the clip, visibility and fade passes ΓÇö the ones that can add channels ΓÇö and before the manifest. Measured on two static production modules: the Scene Exporter had animation armed, the scenes carried no keys, Maya wrote its whole-timelineTake 001AnimStack anyway (a stack and a layer, no curves), FBX2glTF transcribed it asanimations[0]with neither array,verify_glbfailed the envelope, and the manifest pass had named that hollow entry the default clip. A declared take that is still hollow after the writers is dropped too (there is no valid way to ship an empty clip) and logged at warning; the manifest's missing-take warning names that cause.TestPruneGlbAnimations5/5. -
2026-09-02 ΓÇö the FBX handoff block described
shadow_metadataas "shadow-proxy geometry pairing"; it now says what the channel holds (file_utils/mesh_convert/_mesh_convert.py). Per plane: the plane node name, the silhouette texture file name, and the authored intensity. -
2026-09-02 ΓÇö Key stash engine: clips of keyframes parked outside the working animation, inert until retrieved (
core_utils/engines/key_stash/key_stash_model.py). Newptk.KeyStash/StashedClip/StashChanged— the DCC-agnostic record behind mayatk's and blendertk's newKeyStashadapters (Store Keys / Retrieve / Preview / Drop). A clip records WHERE its keys came from and WHEN (per-curve key times — a Graph Editor selection is per key, so the model is per curve, and the clip's range is the envelope); the adapter owns the payload saying where the keys physically live now. Same plumbing as the shot store —ScenePersistencebackend, observer,batch_update, class-levelactive()singleton with frame-rate reconciliation on load — plus the preview record (a scene saved mid-preview is recognised and cleaned up on reopen) and an_on_activatedhook the adapters use to prune records whose storage is gone.rescale_to_fpsmoves the recorded frames by default (Maya keys live in ticks); Blender's adapter overrides it to a rate-only update. Supporting additions toShotStore:is_empty()andinvalidate()(classmethod) — the two calls a scene-persistence adapter needs, so one adapter class can serve either store (MayaScenePersistence(store_cls=…)).test_key_stash_core.py17/17; shots core 138/138 unchanged. -
2026-09-02 ΓÇö a Shot Manifest CSV can be an
http(s)URL, including a Google Sheets share link (net_utils/remote_file.py,core_utils/engines/shots/manifest/manifest_model.py). NewRemoteFileis the one place the ecosystem opens a URL for its bytes:is_url(scheme by NAME ΓÇöurlsplitreports a drive letter as schemec),normalize(a Sheets/edit#gid=share link, the bare/d/<id>, or a published/pubhtmlbecomes the CSV export endpoint, tab kept),openandread_byteswith one timeout and user agent. A sheet that is NOT shared answers its export URL with a sign-in page at HTTP 200, soread_bytesrefuses atext/htmlbody withRemoteFile.Errornaming the remedy instead of letting the parser report "header not found"._read_csv_rowssplit into the source read and a pure_decode_csv_bytes(BOM strip + utf-8/cp1252/lossy chain), so both sources decode identically;parse_csv/from_csv/Mapping.resolvekeep their string signature.AppInstaller._downloadnow opens throughRemoteFile.open(its streaming loop and truncation check are unchanged). Private sheets stay out of scope: an OAuth client would drag a dependency into pythontk.RemoteFile.probe(open + close, body unread; None or the user-facing problem) is the reachability check the DCC panels run off the UI thread while the user types. -
2026-09-02 ΓÇö the live preview is a subpackage,
net_utils/preview/(server.py,deliverer.py,bridge.py,viewer.html,scripts/). One 1700-line module carried three classes and the page and scripts it serves; by the layout rule a module with sibling assets and subfolder dependencies is a package.PreviewServer,PreviewDeliverer,PreviewPassContextandPreviewBridgeare unchanged on the root (ptk.PreviewServer…);pythontk.net_utils.preview_serverstays for one release as a__getattr__alias that warns with the new home.PreviewBridge.push(whole_scene=…)— the alias kept one release afterscope=replaced it — is gone; it had no callers. -
2026-09-02 ΓÇö profiled the WebXR push per option box row, and the converter's cost is the bake, not the bytes (
file_utils/mesh_convert/_mesh_convert.py). End to end on a production assembly (366 MB FBX, 2485 nodes, one 1591-frame take): 419 s, of which FBX2glTF is ~365 s. Stripping every animation object from the same payload brought the converter to 148 s; a 12 MB textureless export of the same scene still took over 300 s — and timed out, becauseconversion_timeoutbudgeted by size alone (10 s/MB, floor 300) and so discarded a push that would have finished. The budget now also reads the file's own census —MeshConvert.bake_node_frames(nodes × take frames at the converter's 24 fps bake, fromFbxFile) atTIMEOUT_SECONDS_PER_NODE_FRAME— and the larger term wins; the size and floor rules are unchanged. The numbers per row are indocs/webxr_preview.md; the.claude/BACKLOG.mdentry proposing to pre-shrink textures before the FBX write was deleted as refuted by measurement, and the two mayatk decisions the profile does price (which takes ship; 602 hidden rig helpers) carry the figures. -
2026-09-02 ΓÇö
FbxMedia: rewrite a binary FBX's embedded textures without a DCC (file_utils/mesh_convert/fbx_media.py), and the preview downsizes its payload's textures to the delivery ceiling before the conversion (net_utils/preview/deliverer.py).FbxMedia.downsize(src, dst, max_size=…)resizes every embedded PNG/JPEG over the ceiling, keeping each one's container, and re-serialises only the record headers (binary FBX stores every record's end as an absolute offset);FbxMedia.embedded()lists what a file carries,rewrite()is the identity round trip a test pins;FbxFile.load(raw_payloads=False)skips the embedded bytes for a census that only counts (the conversion budget uses it) (byte-identical on two Maya-written files, 21 MB and 366 MB).PreviewDeliverergained a third registry,PAYLOAD_PASSES, run on the FBX before the converter reads it; its one entry applies the shared web-delivery ceiling, so nothing the texture pass would keep is lost. Measured quiet, end to end on the assembly above: the push went from 419 s to 333 s — the converter ~365 → ~290 s (a fifth; the bake, not the bytes, is still most of it — see the entry above), the sidecar's inline passes 17 → 9 s and the optimize pass 21 → 10 s, since every later pass now reads 2K images — plus ~600 MB less scratch per push (the SDK's extracted.fbm, the raw GLB). A payload the bridge did not mint is read through a copy and never rewritten or released; a payload that is not a binary FBX is left alone. -
2026-09-02 ΓÇö texture-pass encoder effort set by measurement (
file_utils/mesh_convert/_mesh_convert.py). Lossless WebP'squalityis effort, not fidelity: over a production room's 29 images,LOSSLESS_WEBPat 75 wrote the same 27.7 MB as 100 in 14% less time, and those encodes are the optimize pass's critical path (one 2K normal map Γëê 10 s of it; 8 vs 20 workers made no difference). The packed ORM the sidecar embeds is now written at PNG level 1 (INTERMEDIATE_PNG_LEVEL): it is transport the texture pass re-encodes, and level 6 cost 35% more to write for nothing kept. -
2026-09-02 ΓÇö
ExecutionMonitorbottom-up review: the three Tk helpers and the inline watchdog fold into one self-closing_sidecar.py, the long-execution dialog no longer loses its buttons or acts on an operation that already finished, and the watchdog no longer launches a second Maya (core_utils/execution_monitor/). Bugs fixed, each TDD'd red-first intest_execution_monitor.py(54 → 73 tests): (1)_dialog_viewerwas a fixed 450×180 window, and the fullest message the uitk path builds (Esc hint + the no-checkpoint note + a force button) needs ~244 px — Tk's packer silently drops what does not fit, so the user got the warning with no buttons (probed:reqheight244 vs 180); the dialog now sizes to its content. (2) The dialog blocks the monitor thread until the user answers, so an operation that finished meanwhile still got that answer applied — on the legacy path a late Cancel calledinterrupt_main()after the function had returned, the innocent-bystander async exception the 08-12CancelScopework exists to prevent, still reachable through the dialog. The sidecar dialog is now dismissed the moment the operation ends (show_long_execution_dialog(..., finished=), a new optional kwarg;on_long_executionhands a callback that declaresfinishedthe completion event), and a callback answer that arrives after the function returned is discarded regardless. (3)_spawn_watchdog_subprocessran its inline program undersys.executable— inside Maya that ismaya.exe, so the hard-hang safety valve would have launched a second Maya; it now uses the same resolved interpreter as every other sidecar and is skipped (with a warning) when none resolves. (4)_get_python_executablereturned the host binary itself when nothing python-like sat beside it, so an embedded host withoutset_interpreter(Blender 4.x) "spun" a second copy of the host; it now also looks undersys.prefix/sys.base_prefix(Blender's bundledpython/bin) and returnsNoneotherwise, which every caller treats as "no sidecar". (5) POSIXkill_treeusedkillpgon the host's process group — a host launched from a terminal shares the shell's group, so the watchdog would have taken the user's terminal session down; it now walks/procfor descendants. (6) With aCancelScope, an Esc hold ended the monitor thread, so the threshold dialog — the one place an operation that ignores the request gets explained and offered Force Stop — never came; an Esc request now only sets the flag and monitoring continues. (7) A callback that raised (a logger sink failing) took the monitor thread and the Esc watch with it; exceptions are contained. Structure:_spinner.py+_gif_viewer.py+_dialog_viewer.py+ the 80-line-cwatchdog string (untestable, unlintable) collapse into_sidecar.py(indicator/dialog/watchdogsubcommands; self-contained, Tk imported lazily so the watchdog runs where Tk is absent) — net −2 files and one home for the window setup, arg parsing and the exit-code protocol the monitor reads back through the module's constants. Every sidecar window takes--parent-pidand closes itself when the host dies: a borderless topmost overlay has no close button, so an orphan from a host crash could only be removed via the task manager. Liveness isWaitForSingleObjecton the process, notOpenProcesssuccess — the process object outlives the process while the parent'sPopenstill holds a handle. Also: the Tk dialog is the primary dialog on every platform now (zenity / kdialog /MessageBoxWare the fallbacks), the three hidden-subprocess launch sites share_hidden_popen_kwargs(the existing_hidden_startupinfo, whichpackage_manageralso uses, +CREATE_NO_WINDOW), the heartbeat file is aTempArtifactsallocation (the watchdog kills this process, so nofinallyever reclaims it — the next run's sweep does),is_escape_pressedreturns a real bool, andexternal_watchdogdocuments that a GIL-holding native call starves the heartbeat exactly like a hang. Public API additive only (finished=); private_start_spinner_process/_stop_spinner_processrenamed_start_indicator_process/_stop_indicator_process(no callers workspace-wide). uitktest_cancel_manager43/43 unchanged. -
2026-09-02 ΓÇö
LoggerExt._char_widthresolved its optionalwcwidthimport on EVERY character (core_utils/logging_mixin.py). Theimportsat inside the per-character call, so whereverwcwidthis not installed (mayapy, Blender) each character re-walkedsys.pathΓÇö measured at 2 ms a character: 1.2 s for one scene-export summary box, 2.2 s of a 7 s no-task FBX export. Resolved once and cached on the class. AlsoMathUtils.max_axis_skewis unrolled to plain arithmetic (math_utils/_math_utils.py): the generator form cost 0.04 ms a call, and both DCC shear scans call it once per node per sampled frame (6000+ times per export pass). Same answers, same signature. -
2026-09-02 --
logging_mixinbottom-up review: records are attributed to their caller, a%in a prefix no longer drops the record, zero-width marks measure zero, boxes accept multi-line items, dividers fit the panel (core_utils/logging_mixin.py). Five defects, each pinned red-first intest_logging_mixin.py::LoggingMixinReviewRegressionTest. (1) Every patched level method (info/success/error_once/exception/...) reachedLogger.logthrough two or three wrapper frames inside this module, sorecord.funcName/lineno/pathnamenamed_log_custominlogging_mixin.pyfor EVERY record -- any%(filename)s:%(lineno)dformat, and every ring-buffer record, carried useless provenance._log_customnow advances the stdlibstacklevelpast its own frames (error_once/warning_onceadd their two more); a caller's ownstacklevel=composes on top. Verified on 3.11 and on Metashape's 3.9.13, whosefindCallerwalks frames differently. (2)set_log_prefix("50% ")was spliced verbatim into the %-style format string; the stdlib raisedTypeError: not enough arguments for format stringintohandleErrorand the message never reached the sink. Prefix and suffix are now%-escaped at splice time. (3) The no-wcwidthwidth heuristic (the path every DCC python takes) counted variation selectors (U+FE0F) and the ZWJ as TWO columns and combining marks as one, so a box or table holding a⚠️or a decomposedéwas over-padded; Mn/Me/Cf/Cc now measure zero, aswcwidthreports. (4)log_boxmeasured an item holding a newline as one long row and emitted it as one padded line, breaking the border mid-row, and raised on a non-string item; items and title are nowstr-coerced and split on newlines so a row is one physical line. (5)log_divider()was hard-coded to 60 columns whilelog_boxfit itself tobox_width-> narrowest attached handler ->DEFAULT_BOX_WIDTH, so a rule in a 40-column panel wrapped; both now share_resolve_width. Cleanups:set_log_prefix/set_log_suffixgo through the one wrapper mechanism the other patched methods use;_update_handler_formatterscollapses a redundantFileHandler-and-not-widget type check toStreamHandler-or-RingBufferHandler(the buffer's own formatter now strips HTML like the other plain-text sinks);_log_rawtests a handler's stream withis not Nonerather than truthiness; the dead secondadd_stream_handlerinLoggingMixin.logger(patch already attaches it) is gone;class_loggernames by__qualname__likelogger; inlinetime/hashlib/inspectimports hoisted;logger/class_logger/__init__documented. Module suite 81/81. -
2026-09-01 ΓÇö
ShotStore.declared_range()(core_utils/engines/shots/shot_model.py). The(start, end)frames the active store's shots span, or None when no store is active or it declares none ΓÇö so a caller can fall back rather than read an empty scene as(0, 0). Read throughresolve_clip_specsrather than offShotBlockdirectly, so this range and thefbx_takesan export publishes are rounded by the same code and cannot disagree about a fractional boundary. Exists for the Scene Exporter's new Bake Range dial in both DCCs: asking "what do the shots span?" is computing a number, and must not stamp thedata_exportcarrier to find out ΓÇö that node is a projection, and publishing it is a scene mutation the user may have deliberately switched off. 3911/3911 -
2026-09-01 ΓÇö an unpacked preset now unpacks a LONE packed map instead of quietly keeping it (
core_utils/engines/textures/map_factory/_map_factory.py).filter_redundant_mapscarved out "no loose components present" as sole source of its channels, keep it regardless of workflow — so the same material under the same preset came out packed or unpacked purely on whether a stray loose map happened to sit beside it:{Base_Color, ORM, Metallic}under PBR Metallic/Roughness extracted the uncovered Roughness + AO and retired the ORM, while{Base_Color, ORM}kept the ORM and wired it. A preset has to mean the same thing whatever the material started with, and the machinery for the honest answer was already there — with nothing loose present, nothing is covered, so the existing coverage pass extracts every carried channel and drops the packing. The carve-out is gone, replaced by a narrower guard that keeps the one shape the coverage rule genuinely cannot judge - a packing whosecarried_types()is empty (every channel marked optional;MapType.__post_init__already refuses the no-channelscase) with no loose component standing by. Without it the removal opened a new hole: nothing to check, nothing to extract, so the drop fired and the set's only map vanished - measured, the inventory came back{}. Nothing else moves: the lossless fallback still applies (a packing that is not a readable file,extract_missing=False, or no Pillow keeps the packing rather than losing all of it),extract_channelsstill REUSES a loose map already on disk under the canonical name rather than overwriting it,dry_runstill plans without writing, and a preset that DOES ask for the packing (config_keytrue, or amissing_map_rulepastskip) is untouched. Reached fromMatUpdater.update_networkandGameShaderin both DCC packages. Three tests pin the new shape red→green and the two directions that must not move; the test that pinned the old behaviour survives, correctly renamed, as the pin for the unreadable-source fallback it was actually exercising.test_map_factory_grouping63/63 (each of the four new tests confirmed failing against the code it pins); full pythontk suite 3911 passed; mayatk material suites 214/214; blendertk 434/434. -
2026-09-01 ΓÇö
GlbReader.sampleread a STEP key one frame late whenever the frame's time was not exactly representable (file_utils/mesh_convert/glb_reader.py). glTF stores key times as float32; callers compute the sample time in double ((frame - zero) / fps). For any frame whose time is not exactly representable the two differ in the last bits — measured at 6.4e-08 s for frame 356 of a 30 fps clip, where the stored2.93333339691sits just above the computed2.93333333333— so the exactflat[hi] == timecompare fell through to "hold the previous key". On a STEP channel that is the whole answer, and visibility ships as STEP zero-scale keys, so asking "is this node visible at frame N" returned the wrong side of every transition it landed on. Found while verifying the VDATS assembly, where it manufactured a one-frame pop onFAILED_CMPT_BOARD_LOCthat does not exist in the file — the deliverable was correct and the reader was not. The compare is now a tolerance, relative because float32 keeps ~7 significant digits so the absolute error grows with clip length (1e-6 + |time| * 1e-6; at 66 s that is still 0.002 of a frame at 30 fps, far too small to cross a real key). LINEAR channels were never affected — landing a few ULPs short of a key just interpolates to within those same ULPs — andtest_sample_step_holds_previous_keystill pins the hold-previous semantics away from the keys. Pinned red→green bytest_sample_lands_on_a_step_key_whose_time_is_not_representable, whose fixture keys at88/30exactly because that is the case a round number like0.5cannot reproduce.