Skip to content

pythontk v0.9.35

Latest

Choose a tag to compare

@m3trik m3trik released this 05 Sep 06:12
a03683a
  • 2026-09-05 ΓÇö the console consent seam no longer trusts sys.stdin to know whether it is a terminal (core_utils/app_installer.py). AppInstaller.consent(True, ...) called sys.stdin.isatty() bare. Maya's GUI swaps sys.stdin for a StandardInput proxy with no isatty at all, so every failed-check export in a GUI Maya died with an AttributeError instead of answering no (7 errors in mayatk's 2026-09-04 GUI pass), and a closed stream raises ValueError on the same call. The probe now lives in _console_can_answer(): no stdin, no isatty, 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 by test_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 when cv2 imported, 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 a GLSLShader samples (Raw colour space, colour management and MayaGammaCorrection change nothing), and a 16-bit texture is the fix because sRGB is an 8-bit-only format. New _write_png16 writes 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 through cv2 where it is present.

  • 2026-09-04 ΓÇö the pointer pass is a channel TABLE, and highlight is 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 over glb_fades.CHANNELS -- per row the visibility_tracks key carrying the ramp, the material property it lands on (opacity -> baseColorFactor VEC4 alpha; highlight -> emissiveFactor VEC3, 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 needs alphaMode 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 returned None at 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 authored MASK turned BLEND would 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; new MeshConvert.strip_glb_curve_proxies removes the DCCs' curveProxy-stamped transport nodes (a child <node>__<attr> whose scale.x carries a curve for engines that flatten custom-property animation) BEFORE the clip rebuild counts their channel as content -- measured: hlCube__highlight arrived in the GLB with its 0->1 scale intact; runs first in fbx_to_glb. The preview's fade shim is now a generic material-pointer polyfill (POINTER_BINDINGS: baseColorFactor -> material.opacity, emissiveFactor -> material.emissive as a colour track), which is what a real KHR_animation_pointer implementation 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: its frame_b arrives as the file's -Z (Blender bakes bearing from local X toward local Y, which the Y-up conversion mirrors), so the cross(B, A) up the previous entry introduced came out -Y for 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 +Y in world space, whatever the bearing sense; test_shadow_web.py gains 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_load and _scan_module_attributes both 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 own build(), with six modules read off disk and parsed twice apiece. Every package in the ecosystem boots through this. _parse_module now memoizes into a cache that build() clears on entry and exit, so a rebuild always re-reads from disk ΓÇö which is what ModuleReloader relies 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 two Image.save() calls left in the texture engine, bypassing ImgUtils.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 with ImgUtils.texture_file_types, which lists exr and hdr, and then takes the output extension from the first input ΓÇö while raw PIL can write neither. Reproduced end to end: a .exr set through process_batch raised ValueError: unknown file extension: .exr with 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 guarded numpy/PIL imports 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 for except ImportError passed while the module was still broken: this module has a module-level Layers = List[Tuple[str, Image.Image]] alias and seven Image.Image annotations, all evaluated at import, so a None Image died on AttributeError: '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 annotations defers 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's progress_adapter reads ΓÇö 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 explicit False from the callback raises OperationCancelled before the next entry runs (the set/revert unwinding still runs) ΓÇö the contract prepare_maps adopted the same day; None is 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). @listify changes 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_path was annotated -> str and returns a list, which inspect.signature, help() and API_REGISTRY.md all republished; format_path had 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 ΓÇö including ShotPlanner (the entire timeline planning layer), ManifestModel and PluginInstaller. 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 (\ s instead of \ s) in a docstring written minutes earlier in the same pass. Finally the README claimed domain pipelines "are compositions of these, assembled downstream" while core_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_resources is gone (core_utils/package_manager.py). start_version_check ended in threading.Thread(..., daemon=True).start() ΓÇö no handle, no join, no dedup ΓÇö and check_version is a pip subprocess against another interpreter plus a PyPI round trip. The live caller starts it from a Qt slot constructor: tentacle's HudSlots.__init__ polls tentacletk against mayapy.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, named ptk-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. New version_check_running and wait_for_version_check(timeout) let a host shut down tidily instead of relying on the interpreter killing a daemon thread mid-pip. start_version_check now returns the thread instead of None, which no caller is affected by. Separately, update_requirements resolved versions through pkg_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.metadata has 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 a try whose except printed and appended nothing, while the function writes updated_lines back 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 = MapFactory and _handlers.MapFactory = MapFactory ΓÇö the package's only cross-module attribute assignment, against a no-side-effects-on-import rule ΓÇö while both submodules declared MapFactory = None and called it as a global. Reloading either on its own put the None back, and every primitive call then raised AttributeError: 'NoneType' object has no attribute .... Correction to the standing description of this: it was said to reproduce under ModuleReloader(include_submodules=True), which is the path tentacle's Reload Tentacle package button takes with pythontk explicitly 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 covers module.attr from outside only, and a bare global lookup inside the module raises NameError ΓÇö the attempt was caught by ruff F821 on all 27, not at runtime. _factory is also the DI seam, and the handlers test that patched the injected global is re-pointed in this commit: that name is now absent, so patch.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_pending preferred cls.register_conversions(registry) and fell back to register_from_class, which scans members for a _conversion_info attribute 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 forgot register_conversions a silent no-op; add_plugin now refuses one, and register_from_class is 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_maps had no cooperative checkpoint in either batch branch, so the cancel plumbing that exists end to end ΓÇö execution_monitor sets the flag, uitk's dispatcher catches OperationCancelled ΓÇö 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. OperationCancelled already derives from BaseException precisely so it slips past the broad except Exception handlers 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 (both mat_updater sites, both extapps converter sites, game_shader) leaves max_workers at its default of 1, taking the serial path. New _checkpoint fires 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 a ContextVar, and a ThreadPoolExecutor worker 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_callback returning False now cancels too, the progress-bar update() contract CancelScope.tick is written to match and that Cancelable documents as the free checkpoint; its return value was being discarded. None is 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_ffmpeg and AudioUtils.resolve_ffmpeg were 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 as ptk.resolve_ffmpeg, because vid_utils is 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 passes auto_install=True). Separately, both swallowed AppInstaller.ensure with except Exception: pass ΓÇö and ensure raises 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 raised FileNotFoundError. Guarded by routing (a mock on VidUtils.resolve_ffmpeg catches the audio call) rather than by comparing source text, plus a shape check that the audio body holds no second implementation. shutil became unused in audio_utils and 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_fps is 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_animations did "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. And apply_glb_visibility inverted it ΓÇö channel first, shot_metadata only 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 whose shot_metadata.fps and visibility_tracks.fps disagree gets different visibility key times than before, and the shot system's rate is the one that wins. Pinned by a disagreement test (shot_metadata 24 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 published 0 is 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_handler is attached to every logger, so self.logger.set_text_handler(X) reads as configuring that logger ΓÇö and it assigned LoggerExt._text_handler, a class attribute, because it sat in the direct_methods map, 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) changed Beta().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_handler would 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's telescope_rig / shader_templates / mat_updater and extapps' substance_workflow call setup_logging_redirect with no paired set_text_handler, so whether their log pane got the Qt handler depended on which unrelated panel opened first ΓÇö a dependency extapps/test/test_substance_workflow.py documents and calls a hazard. New LoggerExt.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 pairs set_text_handler with setup_logging_redirect on 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_json and UserConfig.save_file: the halves the classes were missing (file_utils/_file_utils.py, core_utils/user_config.py, +4 call sites). atomic_write_text existed 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.JSONDecodeError alone (x4), ValueError (x3), (OSError, ValueError) (x2), FileNotFoundError, (json.decoder.JSONDecodeError, FileNotFoundError), (TypeError, ValueError). Those are not equivalent: JSONDecodeError does 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_json serialises BEFORE touching the file ΓÇö the difference from the retired set_json, which truncated first, so a value json could not encode destroyed the previous contents ΓÇö then writes through atomic_write_text, creating parents. UserConfig published a loader and no writer, so every caller that needed to persist a config hand-rolled a non-atomic one; save_file refuses a non-mapping, because load_file reads 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_installer catalog, hierarchy_diff, qc_log, the metadata sidecar); the fifth is the deprecated set_json, and a drift guard asserts by FILE that it is the only one left. QcLog.finalize had no test of any kind and now has one. Correction to an earlier read of this: the metadata sidecar's open(path, "w") carried no encoding=, which looks like a cp1252 bug on Windows and is not ΓÇö json.dump defaults to ensure_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 for set_json/get_json now points at these primitives rather than a two-step json.dumps + atomic_write_text idiom.

  • 2026-09-04 ΓÇö ptk.Git and 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. Git resolves, is exported, and has zero references in any of the seven ecosystem packages and no test file of its own. The set_json_file / get_json_file / set_json / get_json cluster is reached only by pythontk's own tests, and it keeps a process-global path created on first write, validates with bare asserts that vanish under python -O, and truncates the file before it serialises ΓÇö so a failed dumps loses it ΓÇö while sitting ~800 lines below atomic_write_text. Its real cost is navigational: those are the only JSON read/write names on the public index, so the documented "check API_INDEX.md first" workflow routes the next author straight into the trap. The warnings live in the bodies because a module-level __getattr__ cannot intercept either ΓÇö Git is 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 that get_json / set_json must 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). _march vectorises 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 is chunk x bins x samples, so raising either parameter multiplied memory silently, and ThreadPoolExecutor's default ran up to min(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 with numpy._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 a RAY_BUDGET of 2^20 elements (_solve_chunk_size, 128 texels at the defaults, halving whenever bins or samples doubles), and the default worker count is capped at MAX_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 explicit threads= still wins, and threads=1 still selects the serial path. The tests assert the invariant (the chunk shrinking as bins and samples grow, 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). _ReusableServer set allow_reuse_address = True unconditionally, with a docstring giving only the POSIX rationale (reuse a TIME_WAIT port so a host relaunch is not blocked). On Windows SO_REUSEADDR additionally 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 _ReusableServer bound the same live port and both ran; with allow_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 policy preview/server.py already reasoned out for the identical pinned-port-inside-a-DCC shape; the two are now asserted together by test_plugin_core.TestBindPolicy so they cannot drift apart again. The four vendored _rpc_core.py payloads (mayatk / blendertk ├ù marmoset / substance) are re-synced by m3trik/scripts/sync_rpc_core.py.

  • 2026-09-04 ΓÇö motion_span could 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 returned None, 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, and check_clips_vs_takes FAILs 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_span trims 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 returns None for a genuinely static channel ΓÇö all three re-measured after the change.

  • 2026-09-04 ΓÇö RemoteFile did not enforce the http(s) contract it documents (net_utils/remote_file.py). urlopen speaks file:, ftp: and data: natively, and nothing on the fetch path consulted is_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 by http(s) URL" and which publishes as ptk.RemoteFile. Every caller today gates on is_url first (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 in open(), the single choke point every fetch passes through. Separately, read_bytes and probe normalize and then hand the result to open, which normalizes again; that is harmless only while normalize is idempotent, which nothing asserted ΓÇö now pinned across all six rewrite shapes.

  • 2026-09-04 ΓÇö a wedged taskkill could hang the watchdog forever (core_utils/execution_monitor/_sidecar.py). kill_process shelled out to taskkill /F through subprocess.run with no timeout, and run_watchdog returns 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 named KILL_TIMEOUT of 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 timeout subprocess.run kills the taskkill child and raises, which the existing best-effort except absorbs ΓÇö 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.py arrived 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 --update regenerates 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: LoggerExt and LevelAwareFormatter (uitk's example wiring, mayatk's scene_audit), and ScriptTemplate with SEND_TO / SAVE_AS / ROUND_TRIP (mayatk's blender_bridge imports all four straight from the module). GlbClips and GlbFades were 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.py has always read TestSandbox.launches and exited 1 on a leak, but .github/workflows/tests.yml runs bare pytest 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 broad except left its test green; the same call opens a tab in production. A pytest_sessionfinish hook sets session.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-level def 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.yml built without clearing dist/ build/ *.egg-info, so a stale build/lib could 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 pinned ruff check job ΓÇö lint only, deliberately not ruff 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/*.txt in package-data but not MANIFEST.in, *.png the other way ΓÇö so pythontk/core_utils/README.md and 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-.py file, 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 missed import maya with 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 ΓÇö leaving net_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.py callers in file_utils, math_utils, str_utils). The decorator builds a ThreadPoolExecutor per 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_case 173x slower on a two-item list, clamp 98x, truncate 152x, split_delimited_string 158x, format_path 49x, move_decimal_point 35x, convert_to_relative_path 18x. time_stamp was measured too rather than assumed I/O-bound ΓÇö its os.path.getmtime is 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_mask KEEPS 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's threading parameter is unchanged and still published. One semantic delta worth knowing: executor.map submits 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 ΓÇö Palette wrapped on two write paths out of seven (core_utils/color.py). Palette(dict) overrode __init__ and __setitem__, so update, setdefault and |= stored raw strings, while copy and | returned a plain dict ΓÇö which then silently stopped wrapping everything written to it afterwards. A Palette holding a raw str is indistinguishable from a correct one until something indexes it as a Color, 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), setdefault returns the wrapped value rather than the raw argument, and the class base stays dict because it is published verbatim and dropping it would break isinstance for external consumers. fromkeys needed no override ΓÇö dict.fromkeys on 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-identical except TypeError: branch that wrote the bare value into WeakValueDictionary.data, the mapping's private backing store. Every read in that container calls what it finds there, so a single handler.label = "text" made lookup, in, keys() and iteration all raise TypeError: 'str' object is not callable ΓÇö not one bad key, the whole namespace. str, int, float, tuple, bool and bytes all 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 stored None reads as absent under use_weakref=True, because WeakValueDictionary cannot tell a None value from a dead referent, while use_weakref=False round-trips it. The module already reads None as 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 in if os.path.exists(asset):, against the only reference to test/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_takes compared 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. New GlbReader.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, None when 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 in collect_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.py 36/36.

  • 2026-09-04 ΓÇö FileUtils can tell whether a file is held open, and by what (file_utils/_file_utils.py). New is_locked, locking_processes and describe_lock, in the family free_space and exceeds_path_length already 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 without FILE_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_locked probes 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_processes names 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 from is_locked. describe_lock is 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 (a FILE_SHARE_READ handle from another process): the probe agrees with both operations it gates ΓÇö os.remove and os.replace each raise there and succeed once released. Tests in test_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 ΓÇö ImageCurator no longer deletes the capture it is about to read (img_utils/image_curator.py). With output_root set to the source's parent and suffix="", the output dir resolves onto the source dir itself and the stale-output purge rmtree'd it. The scan runs first, so its records outlived the delete and every copy2 then failed against a source that was gone -- the capture was destroyed and curate still returned the out_dir, reporting success. ExposureEqualizer.equalize_directories has guarded this exact case since 2026-08 with a normcase/normpath compare 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 raises SameFileError, 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 by test_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, before ensure_image_deps() can provision Pillow, so these modules cache Image = None. blendertk's _rebind_pil_globals repairs that binding afterwards -- but by its own docstring only for "a name the module itself set to None", so the companion PIL_AVAILABLE bool stays False for the life of the session. Ktx2Encoder.encode gated its isinstance(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 handed str(source) -- an image repr where a file path belongs. MaskGenerator.is_available and its operator-facing PIL= diagnostic read the same stale bool, reporting a usable Pillow as missing and returning no masks. Both now test Image 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.py is 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_map escalated jpg -> png from a hand-written five-name list that omitted Metallic_Smoothness, whose registry definition declares mode="RGBA", is_packed=True and channels={"RGB": "Metallic", "A": "Smoothness"} -- URP reads smoothness out of that alpha -- so choosing JPG in a format combo wrote a .jpg that reopens RGB and the smoothness was simply gone. ImgUtils.dropped_channels names 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 through resolve_type_from_path so authoring spellings still resolve (MaskMap -> MSAO), and consulting both channels and mode because neither alone is complete: Metallic_Smoothness declares its mode RGBA while MSAO names its alpha only in channels. Deriving the rule instead of joining it would have silently dropped MRAO and ORM, 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_Smoothness and Emissive_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_tasks returned 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_checks now carries the names beside the existing _last_task_count / _last_check_count stamps, rewritten every run so a passing run clears the previous one's list. _last_skipped_tasks records 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 in test_task_factory.py.

  • 2026-09-03 ΓÇö discover_dir gap-fill now scans the tree, not just the top folder (core_utils/engines/textures/map_factory/_map_factory.py). MapFactory._supplement_sets_from_dir listed the scan directory non-recursively, but every caller that opts into discovery hands over a texture ROOT ΓÇö mayatk's MatUpdater passes the project's sourceImages rule ΓÇö 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.png link asked for the PBR Metallic/Roughness preset and kept its ORM wiring, because the loose Metallic, Roughness and Mixed_AO it needed sat one level down in sourceimages/OFFICE_ENV/ and were never seen; they are now discovered and GameShader._wire clears the packed connections off TEX_metallic_map / TEX_roughness_map / TEX_ao_map as 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_subdirectory pins 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 tested e + rho <= 0 for "below the horizon" where the reference tests e <= 0, it read G at run 0 without the reference's G > 0 guard, and it had no counterpart to the HLSL's second-run blend at all. Neither engine projected the fragment onto the ground plane before forming L, so both measured against a plane one GROUND_OFFSET below the one the map was baked on. The algorithm is now one text: shadow_horizon.glsl, GLSL-authored with an #ifdef SH_HLSL prologue, written term for term against HorizonMap.alpha. A consumer that IS a Python process when it needs the shader assembles it through ShadowHorizon.shader_source(language) (Maya, Blender); one that is not carries a mirror m3trik/scripts/sync_shadow_shaders.py splices between markers and --check guards (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 a vec3 ΓÇö what a Maya .ogsfx uniform, a Blender push constant and a Unity instanced property are all proven to carry ΓÇö and source.w picks a position or the direction the source shines, with sourceSize carrying 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 against HorizonMap.alpha decoding 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 the w = 0 uniform 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 same s at once, so a centred bearing puts s at exactly 0.5, which is a dead tie in side and a boundary in floor(s * 16) decided by whether the GPU's float32 atan2 lands 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 by test_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), and point is 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.alpha is 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. G is the second run's top and is written 0 on 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. _interval takes an optional covered predicate, and a new _run_starts supplies 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) taps breaks a weight tie toward the higher texel, as the shaders do, instead of argmax'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_phi already sums over all four taps and bins k ┬▒ 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.TestTapBlending pins 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 for load could miss the only one it would ever get and leave the scene unshimmed. One await; a module that throws still lets the model load, since loadScripts catches 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 as cot(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.alpha is the numpy reference both engine shaders are pinned to; ShadowHorizon.measure scores it against ImgUtils.rasterize_shadow at random sources and bake_adaptive doubles 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. New ImgUtils.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's scaleOffset. Pure numpy: the twin without PIL composites with it too.

  • 2026-09-03 ΓÇö the shadow rigs reach the WebXR preview: MeshConvert.apply_glb_shadows binds the planes' maps and publishes extras.shadow_web, and the packaged shadow_rig viewer 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 reads shadow_metadata (v1 or v2; a v1 record is filled to a still projected plane) off the data_export carrier, 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 in fbx_to_glb right after the lightmaps, with a new shadow_dirs= searched ahead of the FBX's own directory, a sourceimages beside or inside it and the lightmap dirs. optimize_glb_textures keeps 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_SCRIPTS turns the script on by itself ΓÇö publish() probes a GLB's JSON chunk and activates shadow_rig for one carrying shadow_web, appended to whatever the push named ΓÇö and SCRIPTS registers it. The script gives every plane one ShaderMaterial (projected silhouettes and horizon maps in one program, uMode), batches the projected planes that share a colour map and carry no fade track into an InstancedMesh with 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 of ShadowProjection.model / ShadowModel.placement in model space (rotateY = atan2(ux, uz), scale by the quad's own size, lifted GROUND_OFFSET DCC 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), texelFetch the 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 node KHR_lights_punctual, whose own convention is -Z; the page's lights are its own). test_shadow_web.py 28/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, the fbx_to_glb registration 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-plane InstancedMesh beside 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 past r_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's texture_index, and a map no material points at is fetched through the parser. Measured on the production export (three rigs, one 256┬▓ atlas): one InstancedMesh of three, each instance on its own tile, placement within 1.5e-8 of the ShadowProjection reference; 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 from horizon.max_stretch ΓÇö the scale the map was BAKED at ΓÇö falling back to the record's top-level max_stretch for 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 first load does 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. ShadowModel now carries the projected base and top disk radii (near / length stay 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 / fractions still 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), and ImgUtils.rasterize_shadow (img_utils/_img_utils.py). The geometry under the mayatk / blendertk ShadowRig twins, placed here once so neither DCC carries it. ShadowProjection.project maps 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.model reduces 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 / placement express a rasterized canvas as fractions of that model, which is what re-places a plane at any light position. rasterize_shadow projects 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_triangle gains a max-composited value mode for the per-triangle sigma field), and returns the ShadowRaster it drew into (model, canvas rect, fractions, widest penumbra). A caller passes the model's contact / radius / height so 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_file no longer overrule a deliverer's open_browser (net_utils/preview/bridge.py). Both defaulted to "auto", and that travelled as the request's EXPLICIT answer ΓÇö so a PreviewDeliverer(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 now None ΓÇö defer to the deliverer, whose own default stays "auto" ΓÇö so nothing changes for a caller that never configured one (mayatk/blendertk WebXrPreview, tentacle passes "auto" explicitly). test_push_defers_to_a_deliverer_that_says_no_browser pins it.

  • 2026-09-02 ΓÇö TestSandbox: process-level test isolation for every suite in the stack (core_utils/test_sandbox.py). ptk.TestSandbox.activate() refuses every webbrowser launch for the rest of the process ΓÇö loudly (a RuntimeError naming the fix) and recorded on launches, so a refusal the code under test swallowed still fails the run ΓÇö and routes tempfile.gettempdir() plus TMPDIR/TEMP/TMP into one throwaway root removed at exit (age-swept if the process is killed), so a run's TempArtifacts stores, 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.TestSandbox now extends it, so uitk/tentacle/extapps get it through the call they already make. A test that means to open a page patches webbrowser.open as before ΓÇö mock.patch layers over the guard. test_test_sandbox.py 5/5.

  • 2026-09-02 ΓÇö PreviewServer.stop returns in ~50 ms instead of up to 500 (net_utils/preview/server.py). serve_forever polled at the stdlib's 0.5 s, and shutdown waits 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). New MeshConvert.prune_glb_animations drops 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-timeline Take 001 AnimStack anyway (a stack and a layer, no curves), FBX2glTF transcribed it as animations[0] with neither array, verify_glb failed 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. TestPruneGlbAnimations 5/5.

  • 2026-09-02 ΓÇö the FBX handoff block described shadow_metadata as "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). New ptk.KeyStash / StashedClip / StashChanged ΓÇö the DCC-agnostic record behind mayatk's and blendertk's new KeyStash adapters (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 ΓÇö ScenePersistence backend, observer, batch_update, class-level active() 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_activated hook the adapters use to prune records whose storage is gone. rescale_to_fps moves the recorded frames by default (Maya keys live in ticks); Blender's adapter overrides it to a rate-only update. Supporting additions to ShotStore: is_empty() and invalidate() (classmethod) ΓÇö the two calls a scene-persistence adapter needs, so one adapter class can serve either store (MayaScenePersistence(store_cls=ΓǪ)). test_key_stash_core.py 17/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). New RemoteFile is the one place the ecosystem opens a URL for its bytes: is_url (scheme by NAME ΓÇö urlsplit reports a drive letter as scheme c), normalize (a Sheets /edit#gid= share link, the bare /d/<id>, or a published /pubhtml becomes the CSV export endpoint, tab kept), open and read_bytes with one timeout and user agent. A sheet that is NOT shared answers its export URL with a sign-in page at HTTP 200, so read_bytes refuses a text/html body with RemoteFile.Error naming the remedy instead of letting the parser report "header not found". _read_csv_rows split 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.resolve keep their string signature. AppInstaller._download now opens through RemoteFile.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, PreviewPassContext and PreviewBridge are unchanged on the root (ptk.PreviewServer ΓǪ); pythontk.net_utils.preview_server stays for one release as a __getattr__ alias that warns with the new home. PreviewBridge.push(whole_scene=ΓǪ) ΓÇö the alias kept one release after scope= 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, because conversion_timeout budgeted 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, from FbxFile) at TIMEOUT_SECONDS_PER_NODE_FRAME ΓÇö and the larger term wins; the size and floor rules are unchanged. The numbers per row are in docs/webxr_preview.md; the .claude/BACKLOG.md entry 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). PreviewDeliverer gained 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's quality is effort, not fidelity: over a production room's 29 images, LOSSLESS_WEBP at 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 ΓÇö ExecutionMonitor bottom-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 in test_execution_monitor.py (54 ΓåÆ 73 tests): (1) _dialog_viewer was 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: reqheight 244 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 called interrupt_main() after the function had returned, the innocent-bystander async exception the 08-12 CancelScope work 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_execution hands a callback that declares finished the completion event), and a callback answer that arrives after the function returned is discarded regardless. (3) _spawn_watchdog_subprocess ran its inline program under sys.executable ΓÇö inside Maya that is maya.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_executable returned the host binary itself when nothing python-like sat beside it, so an embedded host without set_interpreter (Blender 4.x) "spun" a second copy of the host; it now also looks under sys.prefix / sys.base_prefix (Blender's bundled python/bin) and returns None otherwise, which every caller treats as "no sidecar". (5) POSIX kill_tree used killpg on 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 /proc for descendants. (6) With a CancelScope, 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 -c watchdog string (untestable, unlintable) collapse into _sidecar.py (indicator / dialog / watchdog subcommands; 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-pid and 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 is WaitForSingleObject on the process, not OpenProcess success ΓÇö the process object outlives the process while the parent's Popen still holds a handle. Also: the Tk dialog is the primary dialog on every platform now (zenity / kdialog / MessageBoxW are the fallbacks), the three hidden-subprocess launch sites share _hidden_popen_kwargs (the existing _hidden_startupinfo, which package_manager also uses, + CREATE_NO_WINDOW), the heartbeat file is a TempArtifacts allocation (the watchdog kills this process, so no finally ever reclaims it ΓÇö the next run's sweep does), is_escape_pressed returns a real bool, and external_watchdog documents that a GIL-holding native call starves the heartbeat exactly like a hang. Public API additive only (finished=); private _start_spinner_process / _stop_spinner_process renamed _start_indicator_process / _stop_indicator_process (no callers workspace-wide). uitk test_cancel_manager 43/43 unchanged.

  • 2026-09-02 ΓÇö LoggerExt._char_width resolved its optional wcwidth import on EVERY character (core_utils/logging_mixin.py). The import sat inside the per-character call, so wherever wcwidth is not installed (mayapy, Blender) each character re-walked sys.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. Also MathUtils.max_axis_skew is 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_mixin bottom-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 in test_logging_mixin.py::LoggingMixinReviewRegressionTest. (1) Every patched level method (info/success/error_once/exception/...) reached Logger.log through two or three wrapper frames inside this module, so record.funcName/lineno/pathname named _log_custom in logging_mixin.py for EVERY record -- any %(filename)s:%(lineno)d format, and every ring-buffer record, carried useless provenance. _log_custom now advances the stdlib stacklevel past its own frames (error_once/warning_once add their two more); a caller's own stacklevel= composes on top. Verified on 3.11 and on Metashape's 3.9.13, whose findCaller walks frames differently. (2) set_log_prefix("50% ") was spliced verbatim into the %-style format string; the stdlib raised TypeError: not enough arguments for format string into handleError and the message never reached the sink. Prefix and suffix are now %-escaped at splice time. (3) The no-wcwidth width 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, as wcwidth reports. (4) log_box measured 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 now str-coerced and split on newlines so a row is one physical line. (5) log_divider() was hard-coded to 60 columns while log_box fit itself to box_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_suffix go through the one wrapper mechanism the other patched methods use; _update_handler_formatters collapses a redundant FileHandler-and-not-widget type check to StreamHandler-or-RingBufferHandler (the buffer's own formatter now strips HTML like the other plain-text sinks); _log_raw tests a handler's stream with is not None rather than truthiness; the dead second add_stream_handler in LoggingMixin.logger (patch already attaches it) is gone; class_logger names by __qualname__ like logger; inline time/hashlib/inspect imports 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 through resolve_clip_specs rather than off ShotBlock directly, so this range and the fbx_takes an 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 the data_export carrier 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_maps carved 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 whose carried_types() is empty (every channel marked optional; MapType.__post_init__ already refuses the no-channels case) 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_channels still REUSES a loose map already on disk under the canonical name rather than overwriting it, dry_run still plans without writing, and a preset that DOES ask for the packing (config_key true, or a missing_map_rule past skip) is untouched. Reached from MatUpdater.update_network and GameShader in 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_grouping 63/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.sample read 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 stored 2.93333339691 sits just above the computed 2.93333333333 ΓÇö so the exact flat[hi] == time compare 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 on FAILED_CMPT_BOARD_LOC that 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 ΓÇö and test_sample_step_holds_previous_key still pins the hold-previous semantics away from the keys. Pinned redΓåÆgreen by test_sample_lands_on_a_step_key_whose_time_is_not_representable, whose fixture keys at 88/30 exactly because that is the case a round number like 0.5 cannot reproduce.