github m3trik/pythontk v0.11.2
pythontk v0.11.2

3 hours ago
  • 2026-09-23 -- ExportProfile.naming_report(version_suffix=) names the release it goes in (core_utils/export_profile.py). Accepted and ignored since the diff baseline went per-scene, and passed by both DCC exporters on every export, so no gate could ever retire it; it warns through Deprecation.parameter until 0.12.0, and mayatk and blendertk stop passing it in this cascade (a published one that still does sees the notice once a session). The retired naming inputs beside it -- the Version pattern, the Timestamp flag, the RegEx field, the {name} token -- still carry a persisted panel field and await a maintainer call, so their notices now say "still honoured" rather than the "one release" several releases have passed. The resolve_output_path docstring said a blank field resolves to context["name"]; it is context[NAME_KEY] (scene). Tests: test_export_profile.py.

  • 2026-09-22 -- a carried ripple window trims the GAP, never the shot it moves (core_utils/engines/shots/shot_plan.py). plan_ripple_downstream(carry_gap=True) opened the first moved shot's window just past after_frame even when that frame was the shot's OWN start (or past it), and plan_ripple_upstream capped the last moved shot's window at before_frame even below that shot's own end. Both assumed the sample on the bound was the pivot's closing pose; for the key-drag expansion it is not -- the handlers ripple BEFORE the dragged keys land, so on a keyed seam the frame holds the NEIGHBOUR's own pose. Measured in a fresh mayapy through the real handlers: a key dragged onto the next shot's keyed start stranded that shot's opening pose one frame into the gap (the shot then opened on nothing -- on the production chain FAILED_CMPT_LOC_opacity 16 -> 15 keys), the upstream mirror stranded the previous shot's closing pose, and a key dragged INSIDE the neighbour tore it (the keys the bound covered stayed behind). The window now opens just past the bound only while the bound sits in the gap; on or past the moved shot's edge that shot keeps its own envelope and fencepost, so it moves whole -- the drag grammar's "neighbours stay intact". Callers anchoring at an OLD bound (every bound change) are unaffected: their bound is always in the gap or on a contiguous seam, where the fencepost reads the same as before. test_shots_core: test_carry_gap_never_cuts_into_the_shot_it_moves, test_carry_gap_upstream_never_cuts_into_the_shot_it_moves, and the corrected premises of test_a_bound_dragged_into_the_neighbour_still_ripples_it / test_insert_and_delete_ripple_without_carry (all four red before the fix).

  • 2026-09-22 -- FileUtils.portable_path / resolve_portable_path: the spelling a scene record stores for a file, and back (file_utils/_file_utils.py). The maintainer's rule of 2026-09-19 -- no absolute links in scene data, resolve relative ones through the project -- had no primitive: relativize_output_dir spells a folder FIELD and deliberately keeps an outside path verbatim, and mayatk's MatUtils.to_project_relative is texture-specific (the sourceimages rule, UDIM tiles). portable_path(path, base) is relative to base when the file lies under it (sound/vo.wav) and absolute otherwise -- the file twin of relativize_output_dir, which it spells through; never a ../ chain, which was tried the same day and withdrawn: read under a project other than the one it was written under, the chain walked off the drive root to a different file (review, measured). resolve_portable_path inverts it and reads an absolute value -- a record written before the rule -- unchanged, including one that merely starts at a root (/audio/x.wav on Windows), which a join would re-home onto the base's drive. First consumer: mayatk's audio file map. test_file: five tests, red before the methods existed.

  • 2026-09-22 -- FileDependencies: the files a scene's records name, found on disk and gathered, for any record that names files (file_utils/file_dependencies.py, file_utils/_file_utils.py, img_utils/_img_utils.py, core_utils/engines/textures/map_factory/_map_factory.py). Both DCC lightmap bakers carried the same few hundred lines to answer three questions about the maps their markers name: where is each file now, which folders should a consumer search, and how do the files get gathered into one folder. None of that is lightmap logic -- any record that names files asks it -- so it is one class here, over plain values. FileDependencies.claims(refs) maps each file name (lowercased) to the owners that read it, from (owner, name, ...) tuples. resolve(refs, search_dirs, walk_root, find_files, resolve_hint) gives one record per file (name, dir, owners, path, found_by "hint" or "search", note): the recorded folder first, then the search folders, then a walk, so a file that moved into a subfolder still resolves. search_dirs(deps, then) lists the folders the files were really found in, then the host's own -- existing folders only, then's included, since a consumer that joins a name against a missing folder finds nothing there. copy_files(sources, dest_dir, mode) copies or moves, reusing a file already there only when its CONTENT is the source's and skipping, never overwriting, a different one -- size is not the test, two bakes of one map are the same size. A destination that IS the source however it is spelled (a junction, a subst or mapped drive) is in place, never a second copy whose removal deletes the only one. Every folder a file's owners recorded is tried before the search, and a recorded folder reaches the host's resolver exactly as stored: forward-slashed, a UNC share became a Blender-relative // path. Both checks are FileUtils primitives, is_same_file(a, b) and has_same_content(a, b). relocate(deps, dest_dir, source_dir, mode, dry_run, find_files, copy) plans and runs a gather and reports relocate / in_place / missing / copied. The finder and the copier are injectable, so each DCC passes its own. Three primitives came out of the same bakers. FileUtils.unique_path(folder, stem, ext, taken, claims, owners, avoid) returns the next free stem[_N].ext that this run has not taken and nobody but owners reads: a name only the owners read stays theirs to overwrite, and a plain collection of names is claimed outright. MapFactory.dominant_texture_set(paths) returns the texture set most of a material's map-typed files share, with its folder; only files named as a map type vote, and a tie goes to the sorted name. ImgUtils.compose_rect(outer, inner) composes two [scaleX, scaleY, offsetX, offsetY] UV rects into one. The lightmap-only half (markers, the manifest, the bake result) stays in mayatk and blendertk. Tests: test_file_dependencies.py (new), TestUniquePath, DominantTextureSetTest, ComposeRectTest; the published-surface snapshot adds FileDependencies, unique_path and compose_rect.

  • 2026-09-22 -- the WebXR preview page exports a still of its current view (net_utils/preview/server.py, net_utils/preview/scripts/snapshot.js, net_utils/preview/viewer.html). A new opt-in viewer script, snapshot, adds an Export Image button: the view the page is showing -- camera, pose and all, without the HUD -- saved as a PNG at the view's own size or rendered at 1080p / 1440p / 4K (the playblast presets' edges, via the same raised-pixel-ratio rule, never an upscale). It posts to a new POST /snapshot route; PreviewServer.save_snapshot places it by the playblast's rule -- beside the published file when it is on disk, else in the serve root, which the page then downloads -- numbered <deliverable>_view_001.png and up (a scene push, which has no deliverable on disk, view_001.png), never overwritten, with the name composed server-side; a deliverable folder that cannot be written to sends it to the serve root instead. PNG only, checked by signature (SNAPSHOT_TYPES) and size (MAX_SNAPSHOT_BYTES), under the Host/Origin checks every writing route carries. Every POST's body is measured against its route's ceiling BEFORE a byte is read (413; 400 for a length that cannot bound the read, 411 for a chunked one) -- rfile.read(n) allocates n up front, so a claimed 8 GB had committed 8 GB of the host DCC's memory -- and an error reason the status line cannot carry (a non-Latin-1 path) is made to fit instead of raising inside the error path. The page's script API gains captureSize(maxEdge), refusal(response) and download(url), moved out of playblast.js so the recorder and the still share one sizing rule and one error/download path.

  • 2026-09-21 -- Optimize Textures OFF resizes nothing, in a GLB too (core_utils/export_profile.py). ExportRun.glb_texture_params gave an OFF row the web delivery ceiling (2048 px), so a setting that reads as "no optimization" downsized every map of a GLB deliverable. OFF now keeps every map's own resolution (re-encoded to the container, max_size 0 -- which also skips GlbPipeline's FBX payload downsize; Secondary Map Size, a row of its own, still caps the data maps it names, and a KTX2 container still takes power-of-two edges); a plain Optimize still names no ceiling and takes the web policy's, and Optimize + Max N caps at N. The ceiling once existed for parity with a preview that downsized on its own; the preview now runs these rows, so both follow them.

  • 2026-09-21 -- Baked Reflections: the lighting recipe carries a level for lightmapped materials, set by the export (file_utils/mesh_convert/_mesh_convert.py, core_utils/export_profile.py, core_utils/scene_records.py, file_utils/mesh_convert/glb_pipeline.py, net_utils/preview/). A baked material takes only the viewer's environment specular, and it played at full strength: the studio environment is brighter than the room the bake lit, so its reflections lifted the darkest baked machine surfaces of a production room from 0.06 to 0.22 of display (a quarter: 0.11). MeshConvert.RENDERING_POLICY.lightmappedMaterials.envMapIntensity publishes the level (0.25 by default), and MeshConvert.rendering_policy(overrides) merges an export's choices over the policy, refusing a field it does not declare. The choice is the EXPORT's: ExportProfile.BAKED_REFLECTIONS_OPTIONS (tokens off / quarter / half / full -- a token, because the export button drops a falsy row as unset), ExportRun.baked_reflections (BAKED_REFLECTION_LEVELS, baked_reflection_level) and ExportRun.rendering, which reaches both carriers as an input: GlbPipeline.envelope(rendering=) / MeshConvert.build_scene_sidecar(rendering=) for the GLB's handoff.rendering, ExportContext.rendering -> SceneRecords.handoff_block(rendering=) for the FBX's handoff record. ExportProfile.GLB_LIGHTING_ROWS declares the row and GLB_ROWS every row a GLB-only producer mirrors, with glb_options() / glb_defaults() (baked_reflections_default). The preview resolves the row with the rest (ExportRun.for_glb) in PreviewDeliverer.preflight and leaves the choice on the request (PreviewDeliverer.RENDERING_KEY) for the host's envelope (PreviewBridge._attach_sidecar(request=)). The viewer reads lightmappedMaterials.envMapIntensity and scales everything the environment gives a baked material by it, with one uniform every baked shader shares (viewer.html).

  • 2026-09-21 -- ImgUtils.denoise_image: edge-preserving denoise of a linear-light (HDR) image within a mask (img_utils/_img_utils.py). A self-guided filter in log space: each window fits the image as a line of its own log-brightness, so a light's falloff passes through exactly, a step well beyond the noise is kept and grain is averaged away; lone spikes are clamped to their 3x3 median first (banded, so a 4K map stays cheap), and the noise level is estimated from the image (robust MAD of the Laplacian) unless given. Only mask texels are read or written, so gutters never leak in -- a NaN or inf texel (a renderer's rare bad sample) included: it reads as absent and, inside the mask, is written from its neighbours, where one would have turned the whole map NaN. Each channel is floored against its own texel's brightness, so a channel at exactly zero barely tints its neighbours. O(texels) at any radius, and built for speed: cv2's box and median filters in float32 on contiguous per-channel planes where cv2 is present (every DCC Python the bakes run in ships it), integral images in numpy where it is not, the two pinned to agree -- measured 12 ms at 256^2 and ~0.2 s at 1024^2, 5.8-7x the first cut (a numpy op broadcasting a plane against HxWx3 runs a strided inner loop: 24 ms against ~1 ms plane by plane). First consumer: mayatk's lightmap baker, whose Arnold bakes had no denoiser (measured on production floor cells: 9% per-texel noise to 1.8%).

  • 2026-09-21 -- the WebXR preview takes the Scene Exporter's texture rows, resolved by the exporters' own method (core_utils/export_profile.py, net_utils/preview/deliverer.py, net_utils/preview/bridge.py, file_utils/mesh_convert/_mesh_convert.py). The preview named a container and nothing else, so every push was cut to the web ceiling (2048 px) whatever the export was set to, while each DCC exporter carried its own private copy of the resolution from its rows to the GLB texture pass. That resolution is now ONE method, ExportRun.glb_texture_params (with ExportRun.glb_max_size), which both Scene Exporters and the preview call. ExportProfile.GLB_TEXTURE_ROWS declares the rows that decide a GLB's images (Texture File Type, Optimize Textures, Secondary Map Size, KTX2 RDO), inside ExportProfile.GLB_ROWS (every row that decides the GLB; see the Baked Reflections entry), and ExportProfile.glb_options() offers their tables to a GLB-only producer, minus what it cannot honour (containers a GLB cannot carry, and Template Budget, whose template conversion the preview does not run). ExportRun.for_glb(values) parses such a producer's row values through from_tasks, and ExportProfile.optimize_textures_tasks is the one decomposition of the Optimize Textures combo (run_config now uses it too). On the preview: PreviewBridge.push(glb_options=...) and PreviewDeliverer(glb_options=...) take the rows (request rows laid over the deliverer's, row by row), resolved in PreviewDeliverer.preflight so a refused row or a missing toktx costs nothing but the check, before the host exports. texture_format still works as {"texture_file_type": ...} and warns until 0.12.0 (Deprecation.parameter). MeshConvert.GLB_IMAGE_FORMATS names the containers a GLB can carry, and web_delivery_texture_params / optimize_glb_textures now accept a container as its file extension (jpg becomes Pillow's JPEG). Untouched rows run the same texture pass in an untouched preview and an untouched export (and since the OFF entry above, keep every map's resolution). (What the rows cannot align is the export work a preview does not run before that pass: a Texture Template's conversion, and Optimize Textures' per-map mode and bit-depth fixes.) The dependency is one way: nothing an export needs is read from a preview.

  • 2026-09-21 -- a baked material takes the WebXR viewer's environment as specular only, and the Light toggle is gone (net_utils/preview/viewer.html, file_utils/mesh_convert/_mesh_convert.py). A lightmap already holds the surface's diffuse lighting, every light and the sky included; the page added a quarter of its environment on top -- diffuse and specular together, per baked material -- and a lightmapped room rendered washed out (measured on a production office: shadows doubled, every surface lifted, reported as "blown out with bake + env"). The environment now plays at one level for the whole model, and on a baked material the shader drops the one line of three.js's lights_fragment_maps chunk that adds its irradiance, keeping the specular term: the reflection-probe role, which is what shows reflections, roughness and normal maps on a baked surface (walls and floor land within 2% of the pure bake, the residual being the floor tiles' own reflections; glossy panels keep theirs -- at Full, the same strength as an un-baked prop beside them). The per-material envMapIntensity dimming and the envMap opt-out it needed are gone, and with them the bake only / bake + env toggle and its l key, which only ever isolated the diffuse this removes. MeshConvert.RENDERING_POLICY states the rule as lightmappedMaterials.envMapTerms: "specular" (the level that specular term plays at is lightmappedMaterials.envMapIntensity, the export's choice since the Baked Reflections entry above) so a recipient reproduces it -- Unity's native lightmap path already does. The live suite now asserts on the PIXELS the page draws: with the environment published at zero a baked matte face lands where the bake alone puts it, with it at full the face stays there, and a baked glossy metal still reflects it.

  • 2026-09-21 -- the GLB lightmap applier tells same-named objects apart, and a material two lightmaps share binds a copy per object (file_utils/mesh_convert/_mesh_convert.py, core_utils/scene_records.py). Two defects behind one WebXR preview of the production room, each putting one object's lighting on another. (1) An exact-name match returned EVERY node of that name, so the first of two BODY records bound machine B's map onto machine A's body as well. _resolve_lightmap_entries now settles each group of same-named records against its same-named nodes as ONE assignment: a record's hierarchy (the DCC path mayatk now publishes) is scored against each node's lineage by shared trailing names, and a record binds only where each is the other's unique best. A record whose node went to a better match is out of scope -- a stale marker on a hidden |TEMP|PROPS|TABLE beside the exported |STATIC|PROPS|TABLE, where scene ORDER used to decide which map the table wore -- and anything still tied, or a manifest without hierarchies, is ambiguous and warned, never guessed. (2) The machines' GLASS is a secondary material on both bodies, each baked into its own map: the second binding was refused ("per-object maps on a shared material -- atlas packing prevents this") and the object wore the first claimant's lighting anyway. Atlas packing prevents neither that nor a Per-Object bake of instances (one mesh, one material, behind every instance node). A conflicting binding now takes its own material clone, and its own mesh entry when instanced, as per-instance rects always did. Each material's carrier slot is read AS AUTHORED before any in-place bind, so a clone of a material another object already bound no longer reports that lightmap as a displaced authored map. lightmap_manifest_coverage / lightmap_report count objects rather than names (records gain entry), and each node's own lightmapInfo marker is corrected from what IT ships: _lightmap_final_values is keyed by node index, where keyed by name the two BODY nodes, now rightly wearing different maps, read as one conflicted name and both markers kept the bake-time .exr at intensity 1.0 (a consumer trusting a marker renders ~10x too dark). Six new tests, red first; test_shared_material_with_different_maps_first_claim_wins pinned the refusal and is now ..._binds_a_copy_per_object.

  • 2026-09-21 -- ExportProfile.CHECK_DEPENDENCIES stops naming check_duplicate_locator_names (core_utils/export_profile.py). mayatk and blendertk deleted that check the same day. It was a deprecated alias of check_duplicate_names("locators"), 13 releases past its window with no caller, so the shared table would otherwise have listed a check neither manager implements. ExportProfile.unimplemented would report it as a gap, and mayatk's empty-gap pin and blendertk's PARITY_GAPS pin would both fail. The entry only drove check scheduling, so a run is unaffected. A headless tasks= dict that still names the key gets TaskFactory's "Missing method" warning, as for any unknown task.

  • 2026-09-20 -- HandoffBridge.child_sys_path: the parent's importable set minus the parent's own Python (core_utils/app_handoff.py). The counterpart to import_roots, for the other kind of child. A script that runs the SAME app as its parent (a bake, a save-as) wants everything the parent can import rather than a named subset -- but "the same app" holds only while the parent IS that app. Driven from a workspace venv instead, handing over the whole sys.path puts the PARENT's stdlib and site-packages ahead of the child's, and a cross-version child dies on the first binary module it resolves to the wrong build: measured, a Blender 5.1 bake driven from a Python 3.11 venv failed with ValueError: unsupported hash type sha256 / ModuleNotFoundError: _sha512, a traceback naming hashlib from a scene bake. Dropping everything under sys.prefix / sys.base_prefix -- and under the per-user site-packages, versioned and built for the parent's Python but outside its prefix -- is inert in production (the child is the same build and already has its own copies of exactly those) and correct everywhere else, so one call serves both. Repo roots, which live outside any interpreter prefix, survive -- those are what the child actually needed. "" and relative entries go too: sys.path's stand-in for the current directory means a DIFFERENT directory in the child. Both bake renderers (btk.MayaSceneImport.render_bake_script and its mayatk twin) now use it in place of repr(list(sys.path)).

  • 2026-09-20 -- UpstreamPatch: one way to correct a defect in code we do not own, and one way to find out it can go (core_utils/upstream_patch.py). A monkey patch without a removal condition is immortal: it keeps overriding a function that has been correct for three releases, nobody can prove dropping it is safe, and the finding that justified it lives in a comment beside the call site or nowhere. This is the mirror of Deprecation, which retires OUR public surface and whose point is remove_in. Here the point is detects -- a probe that reproduces the defect against the STOCK code, so a sweep over UpstreamPatch.registry() asserts every patch is still needed and the release that fixes one upstream makes that sweep fail and name the patch to delete. A patch declares its target as "module.path:Class.attribute" resolved on USE (a package the host does not ship is a no-op, not an import error), the finding that justifies it (mandatory, like Deprecation's replacement), the replacement through replaces (handed the original as its first argument, so nothing has to keep a global holding "the real one"), and the probe. Scope is one attribute for the duration of a with block, handled as STORED (a staticmethod or classmethod target keeps its kind, and an inherited one is handed back to inheritance): nothing installs at import, because a process-wide patch changes behaviour for callers that never asked and produces tracebacks naming a module whose source does not contain the frame. Re-declaring the same name against the same target REPLACES rather than raises, so a module reload (ModuleReloader, and the DCC harnesses that purge a package between modules) does not become an ImportError; the same name against a different target still collides. First tenant: blendertk's io_scene_fbx sibling-armature correction.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.