- 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 scopecomparediffs);Nonewhen 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, soviewer.headset.startread 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'sDiagnosticsinheritedtyping.Any,CoreUtils,XformUtilsandReportDoc, and module-level names leaked asmtk.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_sizereads 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 andformat_table(wrap=True)-- cut a word wider than its line at the line's width, so a path split mid-name (.../gone_Roughoverness.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'stest_scene_auditgoing 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_targetedleaves one dist-info per dist after an upgrade (core_utils/package_manager.py).pip install --target --upgradereplaces 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 -- andpip uninstallremoved that copy by its RECORD and left the current dist-info behind. After the apply,_prune_supersededdeletes 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'saddons/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.osis imported once at module level (three methods imported it locally). - 2026-09-24 --
PackageManagerhands 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 asJos\udce9and Cyrillic as???, while the environment and the working directory arrived intact. Soinstall_targeted's--target(and its--reportfile) 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 runspython [flags] -c <_PIP_SHIM>with the arguments as JSON inPYTHONTK_PIP_ARGV(ASCII whatever they hold, dropped before pip starts); the program is exactlypython -m pip <args>, and a failure still names the-m pipcommand.test_package_manager+4, red first -- the arguments stay off argv, the error wording, a real pip run through the shim, andpip 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 isviewer.specsand the'load'event'sspecs; the page also emits'rendered'after each frame with its CPU times, and gives scriptsviewer.addPanel(title),viewer.addHeadsetCard(...)(the page's headset text card),viewer.formatBytesandviewer.materialsOf-- the page's own size spelling and material walk, so a script's figures match the status line's. - 2026-09-24 -- The
inspectviewer 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 ownviewer.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.buildreturnstimings, seconds per stage in the order they ran --takes,downsize, the conversion asfbx2gltfandpasses(the splitfbx_to_glbnow records in itsreport),reduce,textures,total; a push result adds the host'sexportbefore them andpublishafter, andPreviewBridge.timing_summaryrenders one line ("Push took 333 s: ... FBX2glTF 290 s (87%) ...").lightmap_summarysays "all 46 baked objects bound" / "44 of 46 baked objects bound" rather than "46/46 object(s)". - 2026-09-24 --
MeshConvert.apply_glb_lightmapsno 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_processesfinds 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.MayaConnectionasks 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.exenow means its.exeimage. - 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 inlineaction:///file:///links and toned spans. The HTML targets Qt's rich-text subset, escapes every plain value and carries no newline (uitk'sRichTextFormatterturns 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 ownmode-- 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'swood_<UDIM>.pngmeasured six columns short and its row's separators drifted right;markup=False(whatReportDoc.to_textpasses) 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.pngasfile:///srv/share/a.png-- a folder at the drive root -- and are nowReportDoc.file'sfile://srv/share/a.png(one builder, whichMatReportdelegates to).log_linkHTML-escapes its href, andReportDoc.actionshares 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&verb=x-- measured -- where it meant&verb=x. - 2026-09-24 --
log_tablefits the panel, and the Qt-free text handler draws boxes monospace (core_utils/logging_mixin.py).TableMixin.log_tablecapped every table at 160 columns whilelog_boxandlog_dividerfit 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'sget_redirect_width(), one resolver for both:LoggerExt._reported_width), keeping the 160 cap when neither says.DefaultTextLogHandlermarked records upfont-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 stacklog_boxpins its own rows to, one constant for both (LoggerExt._MONOSPACE_FAMILIES). Withmonospace=False, its constructor default, it also appended raw records bare, so an HTML pane collapsed alog_grouponto one line (measured in a QTextBrowser); a raw record now always gets the monospace, whitespace-keeping wrapper in an HTML sink (ause_html=Falsesink still gets it bare), as uitk'sTextEditLogHandlergives it. Tests:test_logging_mixin+3. The stack is public asLoggerExt.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_windowsscene 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_takesandglb_envelopefailed). 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 throughupdate_shot), and a shot naming no members is never stale.export_recordsleaves stale shots out -- after naming every shot, sosequenceclip names never renumber -- drops members the scene no longer holds, and clears the record when nothing is left;declared_rangespans only what it declares;to_export_viewstill 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 onExportContext.notes, cleared per assembly likerecords, logged byExportSnapshot.assembleand 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_shotsdrops 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.pathsas 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 answersscene_path;writer_stampspells it from the scene's own project andwritten_herejudges a stamp (FileDependencies.written_here) -- the lightmap writers' rule, which each DCC had kept privately;project_rootderives fromscene_path.RecordSpec.pathsmay name the keys that are paths in a payload that is otherwise data:HIERARCHY_BASELINEdeclarespaths=("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_pathsandarriving_pathstake the keys. The baseline's stamp is not in its path set and not hashed, so it changes no diff; an unstamped record readsNone;decode,is_recordandrecorded_byshare 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 plainRuntimeError, and the retry was the user's to find -- reported: Funnel enabled in the browser, and still no link. Givenon_step, a start hands over aStepRequired(url,label; aRuntimeError, 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 noon_step, or once the wait runs out, or when the client names the step and exits, it raises thatStepRequired.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 -- andcancel()retires a tunnel before its link without waiting at all: whatPreviewServer.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, anon_stepthat stops its own tunnel, anon_stepthat raises),test_preview_server+2 (unshare()/stop()ending a share waiting on its step at once; the bridge passingon_stepthrough). - 2026-09-24 -- a view starts where the scene says, and locomotion can be switched off: a camera named
user_pos, andlocomotion(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;fstill 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.pushjoins the start node to any non-empty push whatever its scope, through a new host hook_start_node(name)(Nonehere; 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 byviewer.headset(begin,step(delta, input),end,recenter,read(frame),start) -- which replaces the unreleased combinedviewer.locomotion.updateAPI. 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'suserPosandlocomotion, 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 falseContent-Lengthfrom 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" whereviewer.refusalexists to say why. It surfaced astest_an_oversized_image_is_refusedfailing once under a full run. After the refusal,_drain_refusedreads 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_servertest_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 itsrshortcut 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.locomotionis the rig (rig,toScene,directionToScene, andupdate(delta, input)on plain values -- poses in the tracked space, each stick's[x, y]). The packagedturntableholds 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).