github m3trik/pythontk v0.11.4
pythontk v0.11.4

4 hours ago
  • 2026-09-26 -- a hierarchy baseline adopts each deliverable's history, not only the first one's: HierarchyBaseline.adopt (core_utils/hierarchy_baseline.py). adopt(baseline, shipped) merges what a deliverable last shipped beside whatever the baseline holds of other deliverables, and never over a scope it already holds (relevant_roots, the scope compare diffs); None when there is nothing to adopt. Both DCC baselines adopted only into an EMPTY record, so a scene exporting two deliverables after its record was set aside recorded the first one's scope and never read the second one's sidecar -- the second's next export had nothing to diff and passed a deleted child.
  • 2026-09-26 -- WebXR preview: a push under a turned pivot lands centred, a camera-only push keeps finite clip planes, and headset casts stay cheap on dense meshes (net_utils/preview/viewer.html, scripts/inspect.js). A push while the turntable had turned the pivot measured the model in world space and applied that as a pivot-local offset, so an off-origin model landed off-centre (9.1 m at 45 degrees) and the HUD quoted its turned box's size; it is now measured in the pivot's frame. A push holding only the start camera opened with an infinite far plane and a NaN projection (in a session, a TypeError that stopped the XR loop); an empty model now frames the floor grid. Walking and aiming tested every triangle of every mesh a cast touched, every frame (10 ms on 320k triangles); each mesh's triangles are now bucketed in its own space as a session starts (52 ms for 320k), so a frame tests a few dozen (0.03 ms); skinned, morphed, instanced and multi-material meshes keep three.js's raycast. 'load' now fires after the model is laid out, its start found and the view opened, so viewer.headset.start read in it is the new model's. The missing-UV warning checks the set the bake is sampled on.
  • 2026-09-26 -- A wildcard namespace alias takes only the classes its modules define (core_utils/module_resolver.py). "pkg->Name": "*" took every public class a submodule merely imported, so mayatk's and blendertk's Diagnostics inherited typing.Any, CoreUtils, XformUtils and ReportDoc, and module-level names leaked as mtk.Path / btk.Doc / btk.Any. The expansion keeps a class only where it is defined, as the plain wildcard already did.
  • 2026-09-26 -- ImgUtils.get_image_size reads TGA and DDS sizes from the header (img_utils/_img_utils.py). Only PNG and JPEG were parsed, so an interpreter with no Pillow (Blender's) sized every TGA or DDS map by decoding the whole image -- and Get Scene Info sizes every map a scene reads.
  • 2026-09-24 -- A path too long for its line wraps at its folders (core_utils/logging_mixin.py). LoggerExt's word wrap -- behind boxes, groups and format_table(wrap=True) -- cut a word wider than its line at the line's width, so a path split mid-name (.../gone_Rough over ness.png): unreadable, uncopyable, and invisible to a search, at a point that moved with the length of its folders. It now breaks after the last / or \ that fits, and at the width only where there is none. Found as blendertk's test_scene_audit going red under its runner's longer sandbox temp path (the missing-file table's path wrapped inside the file name) and green without it. Tests: test_logging_mixin +1.
  • 2026-09-24 -- install_targeted leaves one dist-info per dist after an upgrade (core_utils/package_manager.py). pip install --target --upgrade replaces what it moves in by top-level NAME, and a dist-info's name carries its version: upgrading QtPy 2.4.2 -> 2.4.3 left both dist-infos (measured, pip 23.2 under mayapy). pip then read the STALE copy as installed -- every later upgrade plan re-applied the same release -- and pip uninstall removed that copy by its RECORD and left the current dist-info behind. After the apply, _prune_superseded deletes the dist-info of each applied dist whose version is not the one just installed; nothing else in the directory is touched (it may be shared -- Blender's addons/modules), and a failure to tidy never fails the install. Measured end to end with Blender's pip: one dist-info after the upgrade, and a repeat upgrade plans nothing. test_package_manager +1, red first; 51 passed. os is imported once at module level (three methods imported it locally).
  • 2026-09-24 -- PackageManager hands pip its arguments through the environment, so a path with a non-ASCII letter reaches mayapy's pip intact (core_utils/package_manager.py). mayapy.exe decodes its ANSI command line as UTF-8: measured on Maya 2025, an argument "José" arrived as Jos\udce9 and Cyrillic as ???, while the environment and the working directory arrived intact. So install_targeted's --target (and its --report file) through a user folder with such a letter sent pip to a DIFFERENT directory -- a tentacle install under a Maya prefs dir like that failed with "No module named 'pythontk'". _run_pip, the class's one subprocess seam, now runs python [flags] -c <_PIP_SHIM> with the arguments as JSON in PYTHONTK_PIP_ARGV (ASCII whatever they hold, dropped before pip starts); the program is exactly python -m pip <args>, and a failure still names the -m pip command. test_package_manager +4, red first -- the arguments stay off argv, the error wording, a real pip run through the shim, and pip list --path <a dir named like José's> through the real mayapy when one is installed (it listed an empty, mangled dir on the argv route).
  • 2026-09-24 -- The WebXR preview's HUD counts objects, and says what the load found wrong (net_utils/preview/viewer.html). It read "51/57 lightmapped materials": a fraction of three.js material INSTANCES -- one per lightmapped object after the applier's copies, one per loader variant -- over every material, baked or never meant to be. It now reads "46 of 50 objects lightmapped" (the unit the bake is made in, and the panel's push report's), counts the file's objects rather than three.js's per-primitive meshes, gives the size in metres and the file's size in the status line. Where the file's bake markers say which objects were baked, every disagreement is a warning line on the HUD and in the console: a baked object with no lightmap bound, an object lit by a bake it was never in, a lightmapped object without the UV set its bake is sampled on (lightmap_web.uv, the second by default) -- and the skips the rebind used to make silently (a carrier the page does not read, a manifest material the model lacks, a map missing from its slot). The measurement is viewer.specs and the 'load' event's specs; the page also emits 'rendered' after each frame with its CPU times, and gives scripts viewer.addPanel(title), viewer.addHeadsetCard(...) (the page's headset text card), viewer.formatBytes and viewer.materialsOf -- the page's own size spelling and material walk, so a script's figures match the status line's.
  • 2026-09-24 -- The inspect viewer script is a profiler (net_utils/preview/scripts/inspect.js). A panel (i, or its button) of frame time against the display's budget -- the headset's own rate in a session -- and the frames that missed it, CPU time, GPU time where the browser has a timer query, draw calls and triangles per frame; GPU memory estimated from what the model uploads -- block-compressed KTX2 at its transcoded size and a shared image once, where it counted every lightmapped object's atlas texture again and every KTX2 texel as RGBA; what the file's bytes are; and the load's download, parse, setup and first-frame times. In a headset, where the page is not drawn, it rides beside the view as a card -- the page's own viewer.addHeadsetCard -- toggled by B or Y. Copy Report puts it on the clipboard as JSON (in the console where the page has no clipboard -- a plain-HTTP LAN address).
  • 2026-09-24 -- A push reports where its time went (GlbPipeline.build, MeshConvert.fbx_to_glb, PreviewDeliverer, PreviewBridge.timing_summary). GlbPipeline.build returns timings, seconds per stage in the order they ran -- takes, downsize, the conversion as fbx2gltf and passes (the split fbx_to_glb now records in its report), reduce, textures, total; a push result adds the host's export before them and publish after, and PreviewBridge.timing_summary renders one line ("Push took 333 s: ... FBX2glTF 290 s (87%) ..."). lightmap_summary says "all 46 baked objects bound" / "44 of 46 baked objects bound" rather than "46/46 object(s)".
  • 2026-09-24 -- MeshConvert.apply_glb_lightmaps no longer lights an unbaked object that shares a baked one's material (file_utils/mesh_convert/_mesh_convert.py). A binding in place lit every primitive wearing the material, and a bake is per object: a prop the bake never saw -- or a baked object whose map was lost -- sharing the wall's material wore the wall's lightmap (through its own second UV set, or one texel of it), while the report called it unbaked or unlit and the viewer counted the material as lightmapped. When an object the bind leaves unlit wears the material, each baked object now binds its own copy (JSON only; an instance takes its own mesh entry) and the material stays as authored. A map that is found but cannot be read (a truncated EXR) lights nothing either -- it had counted as lit, leaving the material to the neighbour's in-place bind -- and the baked objects on one map share ONE material copy, one per (material, map, rect), where each had made its own (46 identical copies for a room on one material).
  • 2026-09-24 -- AppLauncher.get_running_processes finds a process by its bare name on Windows (core_utils/app_launcher.py). get_running_processes("maya") -- the documented form -- went to tasklist's image filter verbatim and matched nothing, so it was always empty on Windows. MayaConnection asks it whether the PID it launched is still a Maya before closing that instance for a relaunch, so a stale instance that was still running (its port dead) was never closed and stayed behind after the relaunch; its find-by-window-title fallback never found anything either. A name without .exe now means its .exe image.
  • 2026-09-24 -- ReportDoc: a report built once, rendered as HTML for a viewer or as plain text for a console (str_utils/report_doc.py). Headings, key/value fields, tables and bullet items, with inline action:// / file:/// links and toned spans. The HTML targets Qt's rich-text subset, escapes every plain value and carries no newline (uitk's RichTextFormatter turns newlines into <br>); only a table's last (prose) column wraps unless told otherwise, so a name never breaks mid-word. First used by the mayatk / blendertk Get Scene Info report, whose logger-captured <pre> dump it replaces.
  • 2026-09-24 -- MapRegistry.estimate_gpu_bytes: what a map costs once a game build loads it (core_utils/engines/textures/map_registry.py). Block-compressed bytes keyed off the map type's own mode -- BC4 / BC1 (0.5 B/px) for single-channel and RGB maps, BC5 / BC7 (1 B/px) for normals and maps with alpha, BC7 for an unknown type -- with a full mip chain, times the tiles of a UDIM set, beside the uncompressed RGBA8 figure. Shared by both DCCs' scene audits.
  • 2026-09-24 -- TableMixin.format_table(wrap=True) continues a long cell on the lines below instead of clipping it; format_table(markup=False) measures < / > as text (core_utils/logging_mixin.py). For prose columns, where the clipped tail is the part that says what to do (a newline in a wrapped cell starts a line there); the default is unchanged. The width math strips anything tag-shaped, so in a plain-text table a texture's wood_<UDIM>.png measured six columns short and its row's separators drifted right; markup=False (what ReportDoc.to_text passes) counts them as the characters they are.
  • 2026-09-24 -- a report's file link keeps a UNC path's host, and a multi-parameter link reaches its handler intact (core_utils/engines/textures/mat_report.py, core_utils/logging_mixin.py). MatReport's path links spelled \\srv\share\a.png as file:///srv/share/a.png -- a folder at the drive root -- and are now ReportDoc.file's file://srv/share/a.png (one builder, which MatReport delegates to). log_link HTML-escapes its href, and ReportDoc.action shares its URL builder (LoggerExt._action_url) and double-quotes the attribute: Qt decodes entities only in a double-quoted value, so a single-quoted href handed the click handler &amp;verb=x -- measured -- where it meant &verb=x.
  • 2026-09-24 -- log_table fits the panel, and the Qt-free text handler draws boxes monospace (core_utils/logging_mixin.py). TableMixin.log_table capped every table at 160 columns while log_box and log_divider fit the narrowest handler, so a report's table wrapped in the pane its box fit. It now caps a table at the width a box would take (box_width, else the narrowest handler's get_redirect_width(), one resolver for both: LoggerExt._reported_width), keeping the 160 cap when neither says. DefaultTextLogHandler marked records up font-family:monospace, which names no family on Windows, so a Qt pane substituted its proportional UI font and a box's borders missed each other. It now asks for the concrete stack log_box pins its own rows to, one constant for both (LoggerExt._MONOSPACE_FAMILIES). With monospace=False, its constructor default, it also appended raw records bare, so an HTML pane collapsed a log_group onto one line (measured in a QTextBrowser); a raw record now always gets the monospace, whitespace-keeping wrapper in an HTML sink (a use_html=False sink still gets it bare), as uitk's TextEditLogHandler gives it. Tests: test_logging_mixin +3. The stack is public as LoggerExt.MONOSPACE_FAMILIES, which uitk's handler measures in rather than keeping a copy.
  • 2026-09-24 -- an export declares only the shots that still describe something, and says which it left out: ShotStore.stale_shots / remove_stale_shots, the _existing_objects / _keyed_windows scene hooks, ExportContext.notes / note (core_utils/engines/shots/shot_model.py, core_utils/scene_records.py). A Save As copy keeps its source's shot store verbatim: a module forked from an 18-shot assembly, its animated objects deleted, declared all 18 as takes and its GLB -- nothing in it animated -- carried none (clips_vs_takes and glb_envelope failed). A shot is stale when every member is gone from the scene AND nothing is keyed in its window: a keyed shot whose members were renamed keeps its keys there, a hold shot is live while its members resolve (members are stored by name: a host's own renames go through update_shot), and a shot naming no members is never stale. export_records leaves stale shots out -- after naming every shot, so sequence clip names never renumber -- drops members the scene no longer holds, and clears the record when nothing is left; declared_range spans only what it declares; to_export_view still serializes every shot as held. The pure hooks answer "all held, all keyed", so a pure store is unchanged. What a producer leaves out lands on ExportContext.notes, cleared per assembly like records, logged by ExportSnapshot.assemble and kept by the record that noted it (ExportSnapshot.noted), so a host can link each note's remedy. The note names its remedy (ShotStore.remove_stale_shots), never a host's panel. A scene lookup that raises declares every shot as held, with a warning, rather than costing the record. remove_stale_shots drops the records only -- no key, no ripple, one batch -- with the sample claims each removed shot owned (dropped, not disowned: a claim left behind is inherited by the next key set on that curve and frame).
  • 2026-09-24 -- a record names the scene file that wrote it, and keeps naming it across a save into another project: HierarchyBaseline.encode(paths, scene=) / recorded_by, SceneStoreBase.scene_path / writer_stamp / written_here, RecordSpec.paths as a tuple of keys (path_keys) (core_utils/hierarchy_baseline.py, core_utils/scene_records.py). A Save As copy carries every record verbatim, so only the record can say which scene file wrote it. A store answers scene_path; writer_stamp spells it from the scene's own project and written_here judges a stamp (FileDependencies.written_here) -- the lightmap writers' rule, which each DCC had kept privately; project_root derives from scene_path. RecordSpec.paths may name the keys that are paths in a payload that is otherwise data: HIERARCHY_BASELINE declares paths=("scene",), so the save hook re-spells its stamp and not its hash, and a copy saved into another project still names its source -- unspelled, it resolved from the wrong project to a file not there, read as "renamed". map_paths, absolute_paths and arriving_paths take the keys. The baseline's stamp is not in its path set and not hashed, so it changes no diff; an unstamped record reads None; decode, is_record and recorded_by share one read of the schema.
  • 2026-09-24 -- a share waits through a provider's one-time step, handing its caller the page: ShareTunnel(on_step=), ShareTunnel.StepRequired, ShareTunnel.cancel(), PreviewServer.share(on_step=) / PreviewBridge.share(on_step=) (net_utils/share_tunnel.py, net_utils/preview/server.py, net_utils/preview/bridge.py). Tailscale, until the tailnet enables Funnel, prints the page that does it and waits, carrying on by itself once someone does. A share failed at once there, as a plain RuntimeError, and the retry was the user's to find -- reported: Funnel enabled in the browser, and still no link. Given on_step, a start hands over a StepRequired (url, label; a RuntimeError, so a caller catching those still does) and keeps waiting, up to ten minutes since the step is a person's, so the link arrives by itself; with no on_step, or once the wait runs out, or when the client names the step and exits, it raises that StepRequired. stop() now ends a start still waiting on another thread at its next check rather than blocking until the wait runs out -- a stop that lands just as the link does included, and one made by the hook itself -- and cancel() retires a tunnel before its link without waiting at all: what PreviewServer.unshare() / stop() call on a share still starting, whose client would otherwise have brought a link up later in front of nothing. Auto still takes the first INSTALLED provider -- what the machine has, rather than a download it did not ask for. Tests: test_share_tunnel +6 (the step waited through past the link's timeout, a step not taken in time, a client that gives up at the step, a stop cancelling the wait, an on_step that stops its own tunnel, an on_step that raises), test_preview_server +2 (unshare() / stop() ending a share waiting on its step at once; the bridge passing on_step through).
  • 2026-09-24 -- a view starts where the scene says, and locomotion can be switched off: a camera named user_pos, and locomotion (net_utils/preview/viewer.html, server.py, deliverer.py, bridge.py). PreviewServer(user_pos="user_pos", locomotion=True) publishes both in the manifest (userPos, locomotion; guests included) and both are live, read on every poll; PreviewDeliverer(user_pos=..., locomotion=...) is the one place a bridge sets them, applied to the server with every publish and at creation. The start: a headset session arrives on the floor under the camera (the first walkable surface below, so one at eye height stands the viewer where its feet would be), facing its view direction, the posed-from-the-origin first frame black and the view fading in -- only where and which way, eye height, tilt and field of view staying the headset's -- and a session begun before the model loads arrives when it does. The desktop view opens through it: its position, its look and its horizontal field of view (what Maya's film-gate fill and Blender's sensor fit hold; the vertical the file stores assumes the sensor's 3:2, and the clip planes arrive in the wrong units), orbiting about what it looks at; f still frames the whole model. The name matches through a Maya namespace (set:user_pos, which FBX2glTF keeps and three.js's sanitised name drops); a locator or an Empty places the headset only, facing where a camera turned alike would look -- or, for one looking straight down (an unturned Blender Empty), the way the top of that view points. PreviewBridge.push joins the start node to any non-empty push whatever its scope, through a new host hook _start_node(name) (None here; the DCC bridges answer), as the data-export carrier is joined. Locomotion off keeps a session where it started -- under the camera, free to look round and step about the room but not to go anywhere, nothing of locomotion drawn -- and switches a headset already in a session, a guest's included, within a second -- one the sticks had already carried off is taken back to where the session started (the start, or where the rig began without one), through the blink. A recenter (the headset's own) puts a viewer still standing where the start put them back there, facing its way -- always, with locomotion off -- and, while locomotion is on, leaves one who walked off where they are. The page's headset code is now three concerns meeting in one frame: the rig (viewer.rig -- place, toScene, directionToScene, fade: where the headset stands, and the blink), the start, and locomotion (viewer.locomotion -- enabled, aiming, arc, target, vignette: the sticks and all they draw, moving only the rig), stepped by viewer.headset (begin, step(delta, input), end, recenter, read(frame), start) -- which replaces the unreleased combined viewer.locomotion.update API. Verified through the real push in fresh Maya 2025 and Blender 5.1 sessions: an unselected camera under an offset, turned parent -- namespaced, in Maya -- arrives where it stood, looking its way (to 1e-4), with its field of view (to 0.01 degrees). Tests: test_preview_viewer_live +6 (the desktop view through the camera; arriving on the floor under it, facing its way, black then faded in, once per session; a recenter at the start and after walking off; locomotion switched off live, holding the viewer at the camera; a live rename finding a namespaced, Empty-shaped node by its +y; no start), test_preview_server +5 (the manifest's userPos and locomotion, live and for guests; the deliverer setting both on every publish and at creation; a push shipping the start it was not given, once, and never into an empty push); and two tests that bound the production port while they ran (a user's open preview tab would poll them) take an ephemeral one.
  • 2026-09-24 -- the WebXR preview's refusal of an oversized body reaches the client as its 413, not as a reset (net_utils/preview/server.py). A body over its route's ceiling is refused before a byte is read -- that is what keeps a false Content-Length from committing memory -- and the connection then closed with the rest of the body in the socket, which Windows answers with a reset that discards the response the client has not read yet: measured, 5 of 15 posts of 64 KiB - 1 MiB against a 16-byte ceiling read ConnectionAbortedError, and in the page a still or a playblast frame over its ceiling said "Failed to fetch" where viewer.refusal exists to say why. It surfaced as test_an_oversized_image_is_refused failing once under a full run. After the refusal, _drain_refused reads and drops what the client still sends, in 64 KiB chunks and never buffered -- up to 256 MiB on the owner's loopback listener, 64 KiB on a guest's, within 2 s, over at the client's own close. Tests: test_preview_server test_a_refusal_reaches_a_client_still_sending_its_body (eight 1 MiB posts against a 16-byte ceiling; red first, then 5 consecutive clean runs). A body sent with a GET or HEAD is taken off the socket too (_discard_body, within the same ceiling): left unread it was the next request's first bytes on a kept-alive connection, and a refusal's close lost the response to a reset (13 of 400 host-refused GETs carrying two bytes lost their 403).
  • 2026-09-24 -- the WebXR preview gets around in a headset: walk, snap-turn and teleport on the thumbsticks; the fitted mode is gone (net_utils/preview/viewer.html, scripts/turntable.js). The model always stands at its true size, centred on the floor: the Scale toggle and its r shortcut are gone. A headset gets the standard locomotion scheme. The left stick walks where you look -- 2 m/s at full throw through a response curve that keeps the first half of the throw for creeping, eased in and out (0.1 s), a comfort vignette narrowing the view while moving, and the floor followed up a 0.45 m step or down a 1 m drop while a taller ledge holds the height. The right stick snap-turns 45 degrees about the head on a sideways flick, one turn per flick, and pushed forward aims a parabolic teleport arc -- dots flowing toward a cyan ring with an arrow on the heading you will keep, red on a wall or anything steeper than 40 degrees -- that a release takes through an 80 / 140 ms blink, the head landing over the ring at its height; pulled back, it cancels. The viewer moves, never the scene: an offset reference space re-anchors the tracked space (the rig's inverse, built from the session's own space each time), so the model is untouched; the desktop view, which three.js drives from the headset during a session, is saved as the session starts and put back as it ends (position, look, field of view, clip planes, orbit target), and a session draws with a 2 cm near plane rather than the model-sized desktop one, which on a large scene cut away the blink, the comfort vignette and a controller near the face. Controller models load from the WebXR input-profiles CDN (unreachable, they are not drawn and nothing else depends on them); a controls card shows once per session where the viewer first looks and fades on first use; turns and landings tick in the hand. viewer.locomotion is the rig (rig, toScene, directionToScene, and update(delta, input) on plain values -- poses in the tracked space, each stick's [x, y]). The packaged turntable holds still while a headset presents: at true scale it turned the world around the viewer, with its off switch out of reach in the page. The surfaces a jump can strike follow the model: moved as a whole since load -- a pivot the turntable turned before the session -- they are re-read, where a load-time read left every box behind and the arc passed through a wall (found in review, red first). Tests: test_preview_viewer_live +10 on an off-centre fixture of a floor, a step, a ledge and a wall (true scale and centring; one turn per flick, about the head; the aim, the blink and the head over the landing; a raised floor; a wall refused; a wall struck where a turned pivot now stands; a pull-back cancel; the eased walk and its vignette; a step followed and a ledge not; the reference offset the rig's exact inverse), test_preview_server (the pivot is never moved), test_shadow_web (no longer switches scale first).

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.