-
2026-10-10 --
ManifestExport: the Shot Manifest's rows as CSV, JSON or Markdown (core_utils/engines/shots/manifest/manifest_export.py;manifest_model.py,mapping/default.json).records(rows, ranges, statuses, object_name)turns the(step, shot)rows ofShotPairing.rowsinto plain records -- the step ID, its name (Local Edits applied), its shot's name in the scene (they differ until Build renames it), section, description, objects with their behaviors, voice text, frames (a shot's own, else where an unbuilt step would go) and status (an assessment's, elsebuilt/locked/missing_shot;not_in_docfor a shot no step pairs with).tablelays a step's objects out a row each (its own cells on the first, as a sheet's continuation rows) or in one cell, a line each;renderwrites CSV, JSON (each record whole, less the columns left out) or a Markdown pipe table;clipboardgives a table's TSV + HTML for a spreadsheet paste;writepicks the format from the extension, writes atomically, and gives a CSV the UTF-8 byte-order mark Excel needs. Retired with the panel's Copy Asset Names:ManifestModel.asset_column(forManifestExport) andManifestModel.column_clipboard(nowManifestExport.clipboardof a one-column table) warn throughDeprecation,remove_in=0.14.0. The default template's Auto-fill Missing Assets tooltip names Export.- Test(s): new
test_manifest_export.py(18);test_shots_manifest_core.py-- the two retired helpers' tests expect their warning;surface_snapshot.jsonpinsManifestExport.
- Test(s): new
-
2026-10-10 --
ShotStore.rename_members/held_names/OBJECT_SETS: one way to re-point the names a store holds an object by (core_utils/engines/shots/shot_model.py). Shot members and the hidden / pinned / locked sets move together, so a pin follows its member, and two names re-pointed at one node become one member. blendertk'sfollow_renames, mayatk's per-refresh reconcile and its exporter's name repair each had their own loop, and mayatk's two left the pins on the old name.- Test(s):
test_shots_core.pyTestMemberIdentity(+3; red before: no such method).
- Test(s):
-
2026-10-10 --
ShotSequencer.reconcile_system_editsforgets the behavior claims of a deleted curve (core_utils/engines/shots/shot_sequencer.py_forget_deleted_behavior_curves, hooks_curve_exists/_follow_unresolved_claims;shot_ledger.pyauthored_curves). The gap-hold and boundary passes each forget their own register's deleted curves; a curve holding only behavior keys (a highlight, a fade) was neither's, so its claims outlived it -- SOL_REPLACE_ASSEMBLY held 399 on 24 curves its scene no longer had -- and a key later set on a curve of that name at a claimed frame read as the tool's, for the next build to replace. Renamed claims are followed first, so a rename re-keys rather than forgets.- Test(s):
test_shot_sequencer.pytest_reconcile_forgets_behavior_claims_on_a_deleted_curve(red before).
- Test(s):
-
2026-10-10 -- The horizon shadow's penumbra is a round source's: centred on the hard edge, in true angles, with the disc's falloff (
geo_utils/shadow_horizon.glsl,shadow_horizon.pyHeightFieldMap.alpha/interior/disc,INTERIOR_SCALE/INTERIOR_FAR/INTERIOR_MARKER; the viewer and Unity mirrors regenerated). Measured against the disc-integrated hard shadow on the same map: the march's ramp ran outward from the hard edge only (the umbra never shrank, a thin member stayed black at any distance) and measured angles as gaps over the HORIZONTAL distance -- 1/cos too large sideways, 1/cos^2 too large vertically -- so its penumbra was 2-8x too narrow under a high source, 0.18-0.31 mean error over the band, 4-24 % too dark overall. Now the alpha is the share of the source's disc hidden (the circular segment, an S-curve with no Mach bands) of a signed true angle: a lit ray's least clearance, or a blocked ray's depth -- the least turn up over every span above it along the walk, or sideways out of the deepest column (a new interior distance field, in the pyramid tile's free right half under a marker texel, so the layout, the encoding and the record are unchanged and a map baked before it still marches, its inner penumbra from heights alone). 0.05-0.09 band error, total darkness within 2 % on solid props. The bake pads the footprint by the larger of the prop's width and HEIGHT (a penumbra's sideways reach grows with the height it falls from; a 5 cm pole's was cut at 5 mm). The shader's step logic is cheaper per step (closed-form alignment, reciprocal steps, a shift for the cell): 10-15 % on the old ramp; the centred penumbra walks 22 steps on average against 17 (it is 2-8x wider).- Test(s):
test_shadow_horizon.pytest_the_penumbra_matches_the_area_light_integral(red before),test_a_source_size_straddles_the_hard_edge(replaces the outward-only pin),test_a_member_thinner_than_its_penumbra_greys_out,test_a_map_baked_before_the_interior_field_still_marches,test_the_interior_field_rides_the_pyramid_tiles_free_half_under_a_marker;test_shadow_web.py(the GPU pins against the new reference).
- Test(s):
-
2026-10-10 -- The WebXR shadow rig caches the horizon march (
net_utils/preview/features/shadow_rig/shadow_rig.js). Marched per pixel the horizon rig cost 1.5-6x a 2048 PCF shadow map's frame time (16 chairs at one 2048-pixel eye, Iris Xe), doubled by a headset's second eye -- no real-time option. A horizon plane is now marched over its own UVs into its rect of ONE R8 atlas (a texel per three map pixels) and samples it at one fetch a pixel, re-marched only when its placement, the source or the ground moves in the contact's frame, every due plane in one pass, within 65536 texels a frame (a deferred plane keeps the placement it was marched at). Re-marching all 16 every frame now costs about one shadow map (a target and a render per plane had cost four), a still light nothing, and both eyes share one march.session.cache = falsemarches per pixel again;session.cacheStatscounts the marches.- Test(s):
test_shadow_web.pytest_the_cache_tracks_the_reference_across_a_scatter_of_ground_points,test_the_cache_re_marches_only_when_the_light_moves_in_the_contacts_frame; the per-pixel pins turn the cache off.
- Test(s):
-
2026-10-10 -- The GLB articulation applier refuses a rig whose root joints hang under different nodes (
file_utils/mesh_convert/_articulation.py). A rig is solved in one space, its roots' parent; the page refused such a rig at load while the manifest still published it, and unitytk's controller bound it in the first root's space. All three refuse it now, with a warning.- Test(s):
test_articulated_rig_web.pyTestApplyGlbArticulation.test_a_rig_whose_roots_hang_under_different_nodes_is_refused(red before).
- Test(s):
-
2026-10-10 --
RangeResolver.incremental_ranges(core_utils/engines/shots/manifest/range_resolver.py): the range every step builds at once some have shots. Moved down from the Shot Manifest panel'sbuild(), where a test had to copy it (and its copy had drifted): a step with a shot keeps its shot's bounds; a new one takes its typed range, else its last resolved one, else a zero-length range at its predecessor's end. Bug fixed with it: a typed start with no end (Set Start to Current Frame) reached the engine as an end ofNone, and the incremental Build failed with a TypeError; it is zero-length at that frame.- Test(s):
test_shots_manifest_core.pyTestRangeResolver(+2); mayatktest_shot_manifest.pyTestIncrementalBuild.test_a_typed_start_alone_builds_at_that_frame(failed before:(300.0, None)).
- Test(s):
-
2026-10-10 --
RectPacker,ImgUtils.pack_atlas_rectsandImgUtils.uv_axis_scale: atlas cells PACKED at one texel density, each in its surface's shape, a gutter at every resolution (img_utils/rect_packer.py, new;img_utils/_atlas.py,_img_utils.pypack_atlas_rects,atlas_extent(stretch=),uv_axis_scale,atlas_layout_extent). Both DCC lightmap bakers tiled their atlases withcompute_atlas_layout's squarified treemap, which fills the square by bending each cell away from its content's shape, and inset a gutter only as wide as a small cell affords: at 256 px neighbouring cells met 3 px apart against a 4 px rule, and a cell under a texel was never placed.RectPacker.fitfinds the largest UNIFORM scale at which items of natural extents fit (skyline bottom-left with a waste tie-break, bisected) and places them:paddingtexels around every cell, every padded slot on wholealignxalignblocks (BC6H / ASTC / UASTC encode 4 x 4 blocks, so no block mixes two cells), equal sizes in input order, and extents within 0.1% treated as equal (TIE_TOLERANCE) -- Maya reports surface area in single precision, and copies of one mesh traded cells between bakes. Withfold=True(on inpack_atlas_rects), a rectangle longer than the bin at the scale being tried folds toward square at its own area instead of capping the scale for every rectangle: one 100:1 strip held twenty squares to the 4-texel floor, where folded they keep the size they get beside a square of its area.ImgUtils.atlas_extentis the density formula the bakers share (surface area over island coverage); its newstretchturns the box to its shape on the surface, at the same area, andImgUtils.uv_axis_scale(points, uvs)measures it -- the surface one unit of u and of v spans (a UV-area-weighted geometric mean of the per-triangle derivatives: the least-squares choice for square texels, and one a sliver triangle cannot swing, where a root mean square read a 4:1 panel with one sliver as 0.001). A layout stretched to fill its square whatever the mesh's shape -- every wall and baseboard of a production room -- otherwise kept its stretch in any cell of its uv shape.ImgUtils.atlas_layout_extent(area, uvs, stretch=)is both bakers' one reading of a layout -- the box the pack places (cropped byuv_crop_extent's test), the islands' coverage, the stretch -- where each DCC repeated it. On that room's 46-object group at 2048, texel anisotropy on the surface went 2.4:1 median / 16.9:1 worst (the treemap) to 1.0 / 1.05, and density 0.86-1.20 to 1.035-1.06 texels/cm; packing in the LAYOUT's shape alone would have made the baseboards worse (26.9:1). The price is fill: 0.90 -> 0.74 at 2048 and 0.82 -> 0.65 at 256, taken by the gutters, the blocks and the thin strips. Placed inimg_utils(notgeo_utils/uv) because the atlas helpers import it andgeo_utilsranks aboveimg_utils.compute_atlas_layoutstays: the bakers fall back to it for a group too big for 4-texel cells.- Test(s):
test_rect_packer.py(20; the threefoldtests and the zero-tolerance one failed before),test_img.pyPackAtlasRectsTest(12;test_one_long_item_does_not_starve_the_restfailed before -- 4 px against 16;test_the_tiled_layout_this_replaces_loses_the_gutter_therepins the old layout's lost gutter at 256;test_extent_turns_a_stretched_layout_to_its_surfaces_shape, theuv_axis_scaletests andtest_axis_scale_shrugs_off_a_sliverfailed before).
- Test(s):
-
2026-10-10 -- A GLB ramp HELD at a value that changes the render is written, as unitytk plays it (
file_utils/mesh_convert/glb/fades.pyPointerChannel.rest,GlbFades._per_clip). A clip whose ramp holds one value wrote no channel, so a shadow plane over a still prop -- whose baked opacity is intensity x falloff -- drew at the record's intensity alone, a held glow drew none, and a held partial opacity drew opaque. Each channel now names its rest: an opacity held at 1 (the material as authored) or 0 (the gate hides the node), a highlight held at 0 over a black LOW stop; any other held value writes its constant channel (and blends, for opacity).- Test(s):
test_mesh_convert.pyTestApplyGlbFades.test_a_partial_alpha_held_through_a_clip_is_written,TestApplyGlbHighlight.test_a_glow_held_through_a_clip_is_written,test_a_held_zero_glows_only_over_a_coloured_low_stop(all three red before).
- Test(s):
-
2026-10-10 -- The WebXR shadow rig applies a record's intensity once, and reads the horizon ground in the model's frame (
net_utils/preview/features/shadow_rig/shadow_rig.js). Both DCC producers multiplyshadowIntensityinto the opacity they bake, and the page multiplied by the record's intensity again (a 0.9 plane drew at 0.81); it now applies it only where neither a fade track nor a static alpha below 1 already carries it. The horizon ground was read as world Y, so a raised model lost its horizon shadow.- Test(s):
test_shadow_web.py(test_a_static_alpha_the_file_states_is_not_dimmed_again,test_a_raised_model_keeps_its_horizon_shadow, the atlas test re-pinned).
- Test(s):
-
2026-10-10 -- A gap hold's claim knows the key it stepped (
core_utils/engines/shots/shot_ledger.pyShotEditLedger.record_step(value=),claimed_step,is_same_key). Reported: "copy pasting keys in the graph editor is causing earlier animations to lose their stepped tangents". The hold claims its key by frame; a Graph Editor paste over that frame replaced the key, the claim stayed, and the next edit that moved the seam "restored" the pre-hold interpolation onto the animator's pasted -- stepped -- key. A claim now records the key's value;is_same_keytakes a key as the stepped one while its in side (the hold writes the out side only) or its value survives -- a Maya paste lands every key with afixedin side (measured). Claims saved without a value behave as before.claimed_stepreads a claim without releasing it (the sequencers' clipboards copy a held key as it was before the hold).- Test(s):
test_shots_core.pyTestEditLedgerStepIdentity(5).
- Test(s):
-
2026-10-10 -- Delete Empty takes every shot with nothing keyed in it (
core_utils/engines/shots/shot_model.pyShotStore.empty_shots, hook_members_keyed_windows). Reported: "delete empty is leaving behind empty shots". Empty meant "names no object and nothing in the scene is keyed in its frames", but a new shot names the objects keyed on its start (at a zero gap, the shot before's last frame) and a shot whose keys were deleted keeps its members. A shot is now empty when nothing it names is keyed in its frames (a DCC hook, ignoring the shot system's own bound samples); naming nothing the scene holds, when nothing in the scene is. A key on a bound counts only where no neighbour shares the frame, and a shot an audio clip plays through is not empty (hook_audio_windows, the shared_spans_overlap): the Sequencer draws its clips as the shot's content. A stale shot is empty too.- Test(s):
test_shots_core.pyTestScopedShotDeletes(+5, 2 rewritten for the new rule).
- Test(s):
-
2026-10-10 --
ShotSequencer.new_shot: one New Shot for every panel (core_utils/engines/shots/shot_sequencer.py).Shot_<n>, the store'sinitial_shot_length(the Shots window's Initial Length) long, placed byinsert_shot. The Shot Sequencer's New Shot / Insert Before / After were 100 frames (a behavior duration with no behaviors); they and the Shots window's new + button are this call. -
2026-10-10 -- OpenPBR texture sets classify whole: the OpenPBR lobe map types,
MapRegistry.LOBE_TYPE_CHANNELS/LOBE_GATES/TANGENT_NORMAL_TYPES(core_utils/engines/textures/map_registry.py,map_optimizer.py,output_template.py,file_utils/mesh_convert/fbx_media.py). Measured through the live resolver: every OpenPBR / Adobe Standard Material compound map name classified as a BASE map or as nothing --rock_Coat_Roughness,rock_Sheen_Roughness,rock_Diffuse_Roughness-> Roughness;rock_Coat_Normal-> Normal;rock_Sheen_Color,Coat_Color,Emission_Color,Subsurface_Color,Transmission_Color-> Base_Color;Transmission,Specular_Level,Fuzz,Thin_Film-> None -- and the lobe word stayed welded to the base name, so one material grouped as several (rock,rock_Coat,rock_Sheen) and an unclassified lobe map was dropped at inventory build. New types (glTF canonical names; the OpenPBR and Substance spellings are aliases):Diffuse_Roughness,Specular_Level(0.5 = neutral, the authoring convention),Specular_Color(the tint, not the spec/gloss F0),Anisotropy_Angle,Transmission,Transmission_Color,Subsurface_Color,Subsurface_Radius,Sheen_Color,Sheen_Roughness,Clearcoat_Color,Clearcoat_Roughness,Clearcoat_Normal,Thin_Film,Thin_Film_Thickness;Clearcoat/Sheen/Anisotropy/Subsurface_Scattering/Emissivegain the OpenPBR weight and colour spellings.Subsurface_Scatteringis the subsurface WEIGHT (Linear, L, a missing one filled black -- no scattering; was sRGB RGB filled white, which sRGB-decoded every weight map) now that the colour has a type of its own.LOBE_TYPE_CHANNELS(map type -> logical channel) andLOBE_GATES(lobe parameter -> its weight) are the one vocabulary both DCC builders wire OpenPBR from;LOGICAL_CHANNEL_TYPESderives its lobe entries from them.TANGENT_NORMAL_TYPES(the base normals plus the coat normal) is what the encoders now read -- resample as vectors, KTX2 RDO cap, the profile's normal container -- whileNORMAL_TYPESstays the base-normal precedence; read for encoding it would have sized and compressed a coat normal as colour. ThePBR Metallic/Roughnesspreset names OpenPBR as a target: it IS OpenPBR's profile (a key of its own would resolve to the same config, the WebXR precedent).UvTransfer(geo_utils/uv/transfer.py) transfers the lobes too --CHANNEL_TOKENS/NEUTRALgain them (each written under its map type's canonical name, so the output classifies as itself; neutral fills in texture terms),NORMAL_CHANNELSturns a coat normal with its island as it does the base normal, and a source may carryscales({channel: factor}) for a map stored on another scale from the one its output is read on. A set whose OWN name ends in a lobe word keeps its base maps:MapFactory.classify_textures(new) reads each file against its batch, so a lobe-named map that splits into lobe word(s) + a base map (Hero_Coat_Roughness,Truck_Emission_Color) is that set's base map whenHero_Coatis anchored by an unambiguous map or the lobe reading's set (Hero) is not --rock_BaseColor+rock_Coat_Roughnessstays one OpenPBR set while a garmentHero_Coatstays one set of base maps, as in v0.13.0.MapFactory.resolve_map_types(new) is its{path: type}view;group_textures_by_set,dominant_texture_set,sort_images_by_type,filter_images_by_type,prepare_mapsand the channel packers' output names read their batch that way, while the per-fileresolve_map_type/get_base_texture_namekeep the lobe reading.- Test(s):
test_map_factory_grouping.pyLobeNamedSetTest(16),test_map_factory.pyTestLobeNamedSet(2);test_map_registry_ambiguity.pyOpenPBRLobeClassificationTest(6),LobeChannelTableTest(3);test_map_factory.pyTestOpenPBRSet(the set stays one set, the base roughness stays the base's, every lobe passes through);test_uv_transfer.pyTestOpenPBRLobeChannels(3). Red against HEAD (80 failures), bar the two guards that pin what must NOT change (the base maps still classify as base maps; a coat normal is never the shader normal).
- Test(s):
-
2026-10-10 -- A GLB reads the scene's own
data_exportcarrier before a referenced module's (file_utils/mesh_convert/_sidecar.py_data_export_carrier). An assembly export carries oneNS:data_exportper referenced module beside the scene's own, in whatever order the writer put them, and the reader took the first node holding the channel -- so the GLB could read a module's stored lightmap or shadow record while unitytk's importers read another carrier's. Every channel reader now prefers the un-namespaced carrier, and falls back to a module's, first in file order, only where the scene's carries no such channel; unitytk'sCarrierImportapplies the same rule.- Test(s):
test_mesh_convert.pyTestDataExportOverlay.test_the_scenes_own_carrier_wins_over_a_referenced_modules(failed before).
- Test(s):
-
2026-10-10 -- Shot Manifest: the rows a manifest shows, and Build keeps the members of a shot it never built (
core_utils/engines/shots/manifest/manifest_model.pyShotPairing.rows/timeline,manifest_engine.pyShotManifest.bind,manifest_edits.pyManifestEdits.add_step(step_id=)). Found on SOL_REPLACE_ASSEMBLY (57 doc steps, 20 shots, 8 split off in the Shot Sequencer): a split-off shot pairs with no step, so the panel listed it after the last step.ShotPairing.rows(steps, hide_missing=False, orphans=True, keep=())gives the manifest's(step, shot)rows -- the steps in doc order, each shot no step pairs with right after the shot before it on the timeline (one with no paired shot before it leads the next), the steps with no shot left out on request.ShotManifest.bind(shot, step_id)makes a shot a step's (the bindingpairreads first; another shot bound to that step loses it;Noneunbinds).add_steptakes the ID a shot added to the manifest keeps (numbered when taken). Bug fixed with it: a shot with nocsv_objects-- one no build made -- read its members as a previous doc's objects, so a build pairing a step with it dropped every member the step did not list; they now count as members the scene found.- Test(s):
test_shots_manifest_core.pyTestShotPairing(+4: rows in timeline order, hiding missing shots, no orphans, bind),TestReconcileMembership.test_a_shot_the_manifest_never_built_keeps_its_members(failed before: the members dropped);test_manifest_edits.pytest_an_added_step_can_take_the_id_it_is_given.
- Test(s):
-
2026-10-10 -- Section colours no longer read as one gradient:
Palette.contrast_order(core_utils/color.py,core_utils/engines/shots/manifest/manifest_tags.py). Reported: the Shot Manifest's colour by section took the hue-ordered tag palette in turn -- red, orange, yellow -- so neighbouring sections looked alike and the start and end of a section could not be seen.Palette.contrast_order(keys)walks keys by the golden-ratio stride coprime with their count (the two halves in turn for six), so every key is used once and consecutive picks sit about 137 degrees apart;ManifestTags.autotakes its slots in that order (tag1, tag4, tag7, tag2...).- Test(s):
test_manifest_tags.pytest_neighbouring_sections_never_take_neighbouring_hues(failed before: neighbours one slot apart),test_color.pytest_contrast_order_uses_every_key_once_far_from_its_neighbour.
- Test(s):
-
2026-10-10 --
ShotSequencer.describe_key_selection(rows)(core_utils/engines/shots/shot_sequencer.py). The footer line for a key selection, shared by both sequencer panels:"6 keys · frames 100–180.50 (80.50f, 3 frames keyed) · 3 channels on 2 objects"-- how many frames are keyed only when keys share some;""when no key is selected.- Test(s):
test_shot_sequencer.pyTestDescribeKeySelection(4).
- Test(s):
-
2026-10-10 -- The FBX handoff block no longer calls a lightmap record's
intensitya multiplier to apply (core_utils/engines/scene_export/scene_records.py). The bake commits a non-1.0 intensity into the texels, and both readers -- unitytk and the GLB applier -- apply the map as stored; a reader following the old wording ("the multiplier restoring the bake's range") would have applied it twice.- Test(s):
test_mesh_convert.pyTestFbxHandoff.test_states_that_the_named_files_are_embedded.
- Test(s):
-
2026-10-10 -- Log files are UTF-8: a Scene Exporter run's
.logkeeps its closing banner (core_utils/logging_mixin/logger_ext.py,core_utils/engines/scene_export/scene_exporter.py). Measured on real Maya exports: at the panel's ERROR level a clean run's.logwas 0 bytes, and at every level the EXPORT SUCCESSFUL box was missing. All three log-file handlers (setup_file_logging,set_log_file,add_file_handler) opened in the locale's code page -- cp1252 on Windows, which has no box glyphs, check marks or arrows -- so a box or record carrying one raised on write and never reached the file. They open inLoggerExt.FILE_ENCODING("utf-8", new), and the run log's formatter strips markup (StripHtmlFormatter), so the box and links land as plain text, not tags. The banner bypasses the level, so every run that writes a deliverable now records how it ended.- Test(s):
test_scene_exporter.pyTestHooks.test_the_log_file_keeps_the_run_banner_at_every_level,test_logging_mixin.pyFileTeeAndBufferTest.test_a_log_file_takes_any_character_a_record_carries(both failed before:UnicodeEncodeError, empty file).
- Test(s):
-
2026-10-09 -- A Scene Exporter run's
.logrecords the run (core_utils/engines/scene_export/scene_exporter.py). Found on a production export: the.logbeside the deliverable was 0 bytes.setup_file_loggingput its handler on a bare logger named after the class, which neither the exporter nor its task manager logs through (both useself.logger,<module>.SceneExporter); it now sits onself.logger, andclose_file_handlersremoves only that handler -- a caller's own on the same logger stays.- Test(s):
test_scene_exporter.pyTestHooks.test_the_log_file_records_what_the_run_logs(failed before: empty file); mayatktest_an_early_abort_closes_the_run_logreads the handler off the exporter's logger.
- Test(s):
-
2026-10-09 -- An export can narrow a record to what it ships:
ExportContext.scope,RecordSpec.export_scoped,ExportSnapshot.scene_wide/restore_scene_wide(core_utils/engines/scene_export/export_snapshot.py,scene_records.py). Reported: a production FBX failedfbx_dependencieson two lightmaps it did not hold, and shipped three outdated ones. Itslightmap_metadatanamed every lightmapped object in the scene, five of them in a hidden group the export left out, and the post-write embed ships every file a record names.ExportContext.scopeis the export set (None= the whole scene); a record declaredexport_scoped(LIGHTMAPS,SHADOWS: their entries are nodes and name the files that ride the deliverable) is narrowed to it by its producer. A scoped assembly also produces each such record for the whole scene and keeps the copy where the scope narrowed it (scene_wide);restore_scene_wide(store)commits it back, so the carrier the scene keeps -- which a hand-off ships as stored -- still describes everything. Additive: an unscoped context changes nothing.- Test(s):
test_scene_records.pyTestSnapshot.test_a_scoped_assembly_ships_the_export_set_and_keeps_the_scene_whole,test_an_assembly_the_scope_does_not_narrow_has_nothing_to_restore,test_a_scope_that_ships_none_of_a_records_nodes_restores_the_whole(failed before: noscope).
- Test(s):
-
2026-10-09 -- glTF -> FBX with no DCC:
MeshConvert.gltf_to_fbx; KTX2 decoding for an importer that cannot read it:MeshConvert.decode_glb_textures,Ktx2Encoder.decode/decode_all/resolve_ktx;ScriptRunner.failure_summary(file_utils/mesh_convert/_gltf2fbx.py,_fbx_writer.py,_images.py,img_utils/ktx2_encoder.py,core_utils/handoff/script_run.py). Reported: a KTX2 .glb -- the pipeline's own deliverable -- would not import in Maya or in Blender. Blender 5.1's glTF importer refuses any file that requiresKHR_texture_basisu, and Maya had no route at all without Blender.gltf_to_fbx(src, dst, texture_dir=, include_animation=, fps=, manifest=, overwrite=, report=)writes a binary FBX 7.4 (a zero-dependency writer, the encoding twin ofFbxFile) and theHandoffManifestsidecar the Blender pull writes, so mayatk's one consumer reads either. Carried: meshes welded back into shared points, the hierarchy and instancing, normals, two UV sets, vertex colour, PBR maps written as files named for their map type (KTX2 decoded; WebP decoded too -- Maya reads neither -- through Pillow, else FFmpeg, since mayapy has no Pillow), skins (clusters + a bind pose), and the first animation's translation / rotation / scale (cubic and sparse slerped channels resampled at the rate the key spacing names). Lengths in centimetres, Y up, no root rescale. Reported, not carried: morph targets, cameras, lights, further clips. Draco / meshopt geometry is refused. Measured: a production KTX2 assembly (91 nodes, 12 4K maps) converts in 20 s and lands in a fresh Maya with every world position exact; a skinned rig's joints match the glTF's own evaluation to 0.001 cm at frames 0 and 30. A skinned mesh node lands at the root at identity (glTF ignores its transform); when it has children, a<name>_pivotNull in its place carries its transform and animation, so an attachment parented to it stays where the glTF puts it.decode_glb_textures(glb, dst)writes a copy whose KTX2-only images are PNG (Nonewhen every texture already reads everywhere).Ktx2Encoder.decode_all(jobs)decodes a batch side by side, one result per job (measured: ten 2K-4K maps, 29 s -> 9 s); aktxtimeout is aRuntimeErrorfor its image, never a whole conversion.ScriptRunner.failure_summary(output), also set assummaryon a failed run'sRuntimeError: the child's own last error line, clipped to 300 characters, for a message box (the message keeps its output tail for the log).- Test(s):
test_gltf2fbx.py(20, WebP through Pillow and through FFmpeg included),test_fbx_writer.py(7),test_mesh_convert.pyTestDecodeGlbTextures(4),test_ktx2_encoder.pyKtx2DecodeTest(6),test_script_run.pyTestFailureSummary(4).
- Test(s):
-
2026-10-09 -- Desktop shortcuts and the console-free interpreter:
AppLauncher.create_shortcut,AppLauncher.windowless_python(core_utils/app_launcher/_shortcuts.py,_discovery.py,_app_launcher.py).create_shortcut(name, target, args, location="desktop"|"start_menu"|<dir>, icon=, working_dir=, comment=, app_id=)writes a launcher that runs a program: on Windows a.lnkthrough the shell's own UnicodeIShellLinkW(ctypes, no dependency), stamped with an AppUserModelID when given so the taskbar groups and pins the program under it; on Linux a freedesktop.desktopentry (Exec quoted by the spec, executable for a desktop). Writing one again replaces it.WScript.Shellwas the obvious tool and is unusable: it goes through the ANSI code page, so a name or target holding a character outside it (measured: a star) came back as?and the save failed.windowless_python(executable=None)iscompanion_python's console-free twin: thepythonwbeside a Windows Python when one exists (a venv, a python.org install), else the interpreter itself (mayapyand Blender's bundledpythonhave none; pip's gui-script launcher swaps to apythonwwithout looking, which in Blender's Python names a file that does not exist).- Test(s):
test_app_launcher.pyTestWindowlessPython(4),TestCreateShortcut(5: a.lnkkeeping a name and target outside the code page, read back throughShell.Application; replace on rewrite; a.desktopentry's spec quoting; an explicit folder that must exist; macOS refused).
- Test(s):
-
2026-10-09 -- Shot Manifest colour tags:
ManifestTags,ShotStore.manifest_tags, the mapping'scolor_by, andPalette.tags()(core_utils/engines/shots/manifest/manifest_tags.py,shot_model.py,manifest/mapping/_spec.py,mapping/default.json,core_utils/color.py). A step's colour tag is a palette slot key, kept per source on the scene's store (ShotStore.manifest_tags,{source: {step_id: slot}}, keyed asmanifest_editsand read tolerantly):ManifestTags.read/assign(the store is dirtied only by a change). The automatic layer is the template's newcolor_byfield (none|section; its choices AREManifestTags.RULES, so a new rule is one registry entry), offered bydefault.jsonas the Row Colors option;ManifestTags.auto(steps, rule, slots)gives each section a slot in order of first appearance, taken from the sheet, so hiding steps never reshuffles the colours.Palette.tags(): eight categorical colours under neutral slot namestag1..tag8-- saved data stores the slot, never a hue.- Test(s):
test_manifest_tags.py(9);test_shots_core.pytest_manifest_tags_read_tolerantlyand the store round-trip;test_shots_manifest_core.pytest_color_by_offers_exactly_the_tag_rules;test_color.pytest_tags_palette_is_eight_distinct_neutral_slots.
- Test(s):
-
2026-10-09 -- WebXR preview: a menubar, the parts of one scene, clip captions, Export > GLB, private share links and a link check (
net_utils/preview/).- Menubar (
kernel/menus.js): each category on the bar (View, Environment, Inspect, Export, Rig, a script's own) opens a dropdown of items -- an action (a label ending in…asks first), a toggle (the dropdown stays open on one) or a window item opening a persistent panel (kernel/windows.js, checked while open). An item declareswhen()(whether it applies to what is on screen: asked as the menu opens and after each load, guest change and headset session),enabled(), a single-keykey(one table, one owner per key: a second claim is warned and left unbound),groupandorder; a bar button shows only while something under it applies. Arrows walk an open dropdown and step to the next category's, Escape shuts it, a press outside does too. Newviewer.menu(category, {title})->{add(kind, spec), setBadge, refresh, open, close, ...};viewer.window(category, {title, label, key})now files a window item in its menu (itemnew;buttonis the menu's bar button). The Turntable (t), Image…, Playblast…, Inspect > Performance (i) and the Environment probe switches moved into menus; the bar's Frame button is gone -- View > Frame model (f). API stays v1 (additive). - The parts of one scene:
PreviewServer.publish_parts(srcs, labels=None, move=False)publishes several files as ONE version (servedscene.glb,scene_1.glb...; a part past the next publish's count is removed; a repeated name is numbered,model (2).glb);publish(..., label=); the manifest carriesparts([{url, name}]), a guest may read every part,scene.jsonnames them.PreviewDeliverer.PARTS_KEY(a payload's[(path, label)]): each part built as one file is, published together;publish(glb)takes a list andlabels; the result gainspartsandbuilds.FilePreviewBridge.push(objects=[...])pushes every file (all checked before any is built; every source folder searched for lightmaps). The page loads the parts together under one model, each at its authored place: fades and lightmaps per part, every part's clips in one picker (rebound to their own nodes by uuid -- names are unique per file only), the probe and lighting recipe from the first part carrying one; View lists each part to hide it (kept hidden across pushes, by name).viewer.parts; the'load'event gainsparts(itsgltfis the primary's). - Export > GLB (
kernel/parts.js): saves the file the page shows, each part under its own name -- a share's guests too.viewer.download(url, name). - View > Clip descriptions (
kernel/captions.js): the shot on screen captioned with its Shots-panel note (the segment's on FULL SEQUENCE), offered when a clip has one, on by default, remembered per browser; never in a still or a playblast. - Private share links:
PreviewServer.share(private=True)(andPreviewBridge.share) puts the share under a fresh random path segment (16 characters, 96 bits): the guest listener answers nothing outside it (404; the bare segment redirects inside), compares it in constant time, and keeps no alias;share_info()gainsprivate. A link is good for one share -- Tailscale's, otherwise the machine's for good, becomes disposable. - Link check:
PreviewServer.ping_share(timeout, attempts, wait)(andPreviewBridge.ping_share) asks the tunnel's own link for the guest manifest from this machine and passes only when THIS share at this version answers -- never counted as a guest; a miss is asked again before it is reported. - Test(s):
test_preview_viewer_live.py(test_the_bar_carries_one_menu_per_category,test_a_menu_is_walked_and_shut_from_the_keyboard,test_the_parts_of_a_scene_load_together_and_each_can_be_hidden,test_the_playing_shots_description_is_captioned_on_request; the window tests drive menus);test_preview_server.py(parts: 6; deliverer: 2; private links: 4; ping: 3; the shortcut check reads menu keys).
- Menubar (
-
2026-10-09 -- The naming convention ships a read-only
defaultpreset;core_utils/naming_convention.pyis now thecore_utils/naming_convention/subpackage.NamingConvention.preset_store()gains a built-in tier (BUILTIN_PRESETS_DIR, the package'spresets/) holdingdefault.json--DEFAULTSas a preset, so the Naming panel's preset combo always offers the way back to the shipped table (loading it leaves no personal override). The move is internal: the facade lives in_naming_convention.py, andpythontk.core_utils.naming_conventionstill resolvesNamingConvention,AffixRuleand the config constants (lazy_exports); surface snapshot updated. Test(s):test_naming_convention.pyConventionSnapshotTest(+3, one a drift guard holdingdefault.jsontoDEFAULTS). -
2026-10-09 --
X11writes window properties:set_cardinal_property,delete_property,has_property(core_utils/x11.py). Format-32 CARDINAL items (an empty list is an empty property, not none), flushed at once since the connection is never pumped;has_propertychecks the root window by default -- how a window manager advertises a feature (KWin's blur effect sets_KDE_NET_WM_BLUR_BEHIND_REGIONthere). For uitk's blur behind translucent windows on KDE. Test(s):test_x11.py(3 on the recording libX11, any OS;test_a_property_round_trips_on_a_real_windowon a real server -- WSL Xvfb: 12 passed). -
2026-10-09 -- KTX2 lightmaps, and normal maps resampled as normals (
file_utils/mesh_convert/_textures.py,fbx_media.py,img_utils/,core_utils/engines/textures/map_optimizer.py).MeshConvert.LIGHTMAP_BASIS = ("UASTC", True): in KTX2 mode a lightmap with power-of-two edges now takes UASTC + sRGB (never ETC1S, never RDO, still never resized) instead of lossless WebP/PNG -- a 2048 atlas is 5.6 MB on the GPU instead of 22.4 MB. Measured on three production atlases against the HDR bake: median error 0.48-1.52% vs 0.45-1.33%, ~1.7x the wire bytes. An off-size atlas, orLIGHTMAP_BASIS = None, keeps the lossless container.ImgUtils.resize_image(image, x, y, normal_map=False):normal_map=Trueresamples in float and renormalises to unit length. A plain 8-bit Lanczos clipped its overshoot at hard edges -- a production 4K normal atlas halved had 0.07% of texels off by more than 3 degrees (worst 56); now every texel stays within 0.4. Maps that are not outward unit vectors resize plainly. Used byoptimize_glb_textures(by slot, in every container -- the slot is now classified in PNG/JPEG mode too),FbxMedia.downsize(by registry filename type) andMapOptimizer(planflags a normal map's resize/POT ops).- Test(s):
test_mesh_convert.pyTestOptimizeGlbKtx2(5 lightmap tests replacing 2, +test_a_normal_map_is_resampled_as_one);test_img.py(3);test_map_optimizer.pyTestNormalMapResample(2);test_fbx_media.pytest_downsize_resamples_a_normal_map_as_one.
-
2026-10-08 --
ExportProfile.CHECK_DEPENDENCIESdropscheck_uv_snapshots(core_utils/engines/scene_export/export_profile.py). mayatk retired the export check: Auto Unwrap's UV backup can no longer outlive its unwrap (mtk.UvUtils.uv_snapshot). -
2026-10-08 --
FileUtils.deliver_file(staged, destination)(file_utils/_file_utils.py). Puts a finished file at its destination once -- a.partbeside it, promoted byatomic_write-- or, when the destination is held (a viewer, a sync client uploading it), parks it beside as<stem>_unplaced_vNNN<ext>and returns that path with the reason. The mayatk / blendertk Scene Exporters deliver their locally staged FBX and GLB through it.- Test(s):
test_file.pyDeliverFileTest(2).
- Test(s):
-
2026-10-08 --
MeshConvert.fbx_media_dirs(fbx_path)andGlbPipeline.build(shadow_dirs=)(file_utils/mesh_convert/). The folders a file an FBX names loosely is looked for beside it (its own,sourceimagesinside and beside it), now one methodfbx_to_glbuses;buildforwardsshadow_dirs, so a host converting a STAGED copy (mayatk / blendertk Scene Exporter) still searches where the file ships.- Test(s):
test_glb_pipeline.pytest_a_staged_build_searches_the_deliverables_folders.
- Test(s):
-
2026-10-07 -- A duplicated rigged mesh keeps its own skin weights through FBX2glTF:
GlbSkinWeights,MeshConvert.restore_glb_skin_weights(glb, fbx)(file_utils/mesh_convert/glb/skin_weights.py,_fbx2gltf.py). FBX2glTF 0.13.1 merges identical vertices across the whole file, and itsRawVertex::operator==compares everything but the weights (skinningInfo), so a vertex of a skinned mesh bit-identical to one read earlier became that vertex, its weights read against the later mesh's own joints. A production pair of electrical plugs, the second a duplicate rigged with its chain reversed, shipped 65 of 162 vertices bound to the wrong end of the chain (3.76 cm off at the end of its shot; the FBX itself was exact).fbx_to_glbnow restores them from the FBX's clusters first in its session: it reads the FBX only when a skinned mesh shares a vertex position with another mesh, maps each vertex back to its control point by the position FBX2glTF computed (UVs split coincident points), and rewritesJOINTS_n/WEIGHTS_nwhere they disagree (heaviest first, normalized). An accessor another site also reads is replaced for the primitive being fixed, never rewritten under it. A suspect mesh the FBX cannot account for (a name not found or not unique, an unmappable vertex) is left as shipped and REPORTED, never passed quietly.GlbSkinWeights.auditis the read-only form (compared/mismatched/ambiguous/refused), and a new verifier gate reads it:ExportVerifier.check_glb_skin_weights(glb_skin_weights) FAILs naming the mesh, WARNs on a refused one, and says when no skinned mesh could have been merged. Test(s):test_glb_skin_weights.py(TestSkinWeightsThroughTheConverter, 8 incl. the converter-alone premise and coincident points split by UV;TestSkinWeightsGate, 3). -
2026-10-07 -- A refused FBX rewrite takes its
.partwith it, and a large file's replace waits longer (file_utils/mesh_convert/fbx_media.py,file_utils/_file_utils.py). Every in-place FbxMedia pass (embed,embed_dependencies,drop_takes,drop_apparatus, the image rewrites) wrote a sibling.partand promoted it with a bareFileUtils.replace_file; a refusal left the.partbeside the target. In a synced export folder a sync client still reading the freshly written 218 MB FBX outlasted the replace's one-second wait, so the rig-helper drop and the lightmap embed were both lost (the verifier'sfbx_dependenciesFAIL) and four full-size.partfiles (~970 MB) stayed in the folder.FbxMedia._promoteremoves the.partwhen the replace is refused and leaves the target as it was;FileUtils.replace_file(src, dst)now waits out a momentary lock for about a second plus 0.1 s per MiB of src (capped at 60 s; a small file's schedule is unchanged), in 0.5 s steps past the first second. Test(s):test_fbx_media.EmbedTestCase.test_a_refused_promotion_strands_no_part_and_keeps_the_file,test_file.ReplaceFileTest.test_a_large_file_earns_a_longer_wait. -
2026-10-07 -- Copy to Shot, a shot edit every panel hears of, and a manifest build that keeps what the hand changed (
core_utils/engines/shots/).ShotSequencer.copy_sequences_to_shot(sequences, dest_shot_id):move_sequences_to_shot's placement with the originals left in place -- both are now_send_sequences_to_shot(copy=); a DCC supplies the newcopy_attribute_keyshook (pure default: nothing, no scene). Audio is not copied. A source keeps its content and its members.ShotsEdited(label): every store'sscene_editends by emitting it when the body changed the shot records (changed_since_snapshot) -- the one word a panel that did not make the edit gets of the engine's direct bound writes (trim, resize, ripple, respace).ActiveShotChanged.previous: the shot the change replaced (None: it filled an empty slot). New root nameShotsEdited(surface snapshot updated).ShotManifest(store, match, rebuild_edited=False, disabled_behaviors=()). A build records each shot as it left it (metadata["built"]: length, member keys, description; the name already hadbuild_name), and a rebuild keeps a shot edited by hand since (edited_by_hand, plan action"kept": rides an upstream ripple, nothing else; no behavior pass, no dropped-behavior release) unless rebuild_edited. A shot built before the record existed reads as unedited unless renamed.StepStatus.edited/ status"edited"(grey,SHOT_PALETTE), neverneeds_build. disabled_behaviors are neither keyed (Behaviors.apply_to_shots(skip_behaviors=), anchors still spread over the whole list) nor released, nor reported byassess, nor re-keyed byreapply_object.- Test(s):
test_shots_core.pyTestShotsEdited(4);test_shots_manifest_core.pyTestEditedShotsAreKept(11),TestDisabledBehaviors(3),TestStatusRollup(+1), two existing tests moved to the new contract (a hand rename and a removed member are kept unlessrebuild_edited).
-
2026-10-07 -- Shot deletes by scope, and the active shot follows an undo (
core_utils/engines/shots/shot_model.py).ShotStore.empty_shots(): the shots with nothing keyed inside their frames (the rule as finally shipped is the 2026-10-10 "Delete Empty" entry) -- the twin ofstale_shots, which takes the shots whose named objects are all gone.shots_in_scope(scope)/remove_shots_in_scope(scope)withDELETE_SCOPES = ("stale", "empty", "all"): records only, one batch, no key and no other shot touched -- what the DCC panels' single Delete Shots button runs. A restore point now carries the active shot (_restore_point["active"]), so undoing a new shot returns to the shot that was active before it and redo makes the new one active again; andremove_shotdrops the active id when it removes that shot (anActiveShotChanged) -- it was left naming a shot that no longer existed.changed_since_snapshot(): whether the store differs from its newest restore point -- how mayatk'sscene_edittells an edit that changed only shot records (it gets its own undo step) from one that changed nothing (no dead Ctrl+Z). Test(s):test_shots_core.pyTestScopedShotDeletes(6),TestBoundaryLedger.test_undo_and_redo_carry_the_active_shot,test_removing_the_active_shot_clears_it,test_changed_since_snapshot. -
2026-10-06 --
ShotSequencer.set_shot_edge(shot_id, new_start=None, new_end=None, scale=False, push=False): the bound-only edge edit, shared, and able to push into the next shot (core_utils/engines/shots/shot_sequencer.py). It was each DCC controller's private_set_shot_edge, kept twice. With push (the Ctrl drag) an edge may cross the neighbour's facing bound, which gives way and keeps at least a frame; a neighbour flush with the edge follows a shrink, so the seam moves. Both edges given inverted are normalised (asresize_shot_boundsdoes) rather than stored inverted, and a change marks the store dirty. It held at the neighbour, so at a gap of zero Ctrl could not grow a shot at all (SOL_REPLACE_ASSEMBLY, 2026-10-06). The frames that change hands take membership with them (_hand_over_members, new hook_member_names);_find_keyed_transforms(..., evaluated=True)reads each curve at the window's ends too, since the frames handed over usually hold one key of what moves through them. Test(s):test_shot_sequencer.pyTestShotEdge,TestShotEdgeMembers. -
2026-10-07 --
MainThreadMarshaller.post(fn, *args, **kwargs): queue a call for the Qt main thread without waiting (net_utils/rpc/plugin_core.py). For a host callback on one of the host's own worker threads (Maya's Evaluation Manager firesMDagMessagematrix callbacks there), whererunwould deadlock andQTimer.singleShot(0, ...)never fires. Queued even on the main thread, so it always runs outside the caller's stack; runs in place without Qt.disable_envis optional now. The plugin mirrors are re-synced (sync_rpc_core.py). Test(s):test_plugin_core.py(test_post_from_a_worker_returns_at_once_and_lands_on_the_main_thread,test_post_on_the_main_thread_runs_on_a_later_loop_pass,test_post_runs_in_place_on_the_opt_out,test_the_opt_out_env_is_optional). -
2026-10-06 --
MathUtils.best_rotate_order(solutions, current=None, gimbal_margin=10): the Euler order that keeps a keyed rotation on the fewest channels (math_utils/_math_utils.py). DCC-neutral scoring for mayatk's and blendertk'sXformUtils.set_rotate_order(order="auto"): the caller re-solves its keys into each candidate order, and the order whose key-to-key steps move the fewest channels wins; one whose middle channel nears +/-90 at any key (gimbal lock) only wins when all do; the current order wins ties, so a one-axis hinge is left alone. Test(s):test_math.py(test_best_rotate_order_keeps_the_turn_on_one_channel,test_best_rotate_order_avoids_gimbal_lock,test_best_rotate_order_keeps_the_current_order_on_a_tie). -
2026-10-06 --
ShotStore.member_nodes(name): every scene node a doc object name answers to (core_utils/engines/shots/shot_model.py). The scene hook the DCC stores now override;resolve_memberreads it (one hit is found, several ambiguous, none missing), so a caller that needs the duplicates themselves, to select them for fixing, gets them from the same lookup. Test(s):test_shots_core.py(test_resolve_member_reads_the_nodes_a_name_answers_to). -
2026-10-06 -- The Shot Manifest's edit layer:
ManifestEdits, and a step's ID no longer has to be its shot's name (core_utils/engines/shots/manifest/manifest_edits.py,manifest_model.py,manifest_engine.py,shot_model.py).ManifestEditsrecords a user's changes to a build sheet against the sheet's own identities -- steps renamed, re-described, hidden or added; objects renamed, re-behaviored, hidden or moved -- andapply()composes them over freshly parsed steps without touching them (stale()names the edits a changed sheet gives no place to).BuilderStep.name/shot_namerename a step's shot whilestep_idstays its binding, andBuilderStep.sheet/BuilderObject.sheetcarry the sheet's value of each edited field. A build creates a shot undershot_name, renames a paired shot to an explicit name, and names it back to its step ID once the name is gone -- only while the shot still has the name a build gave it (ShotManifest.BUILD_NAMEmetadata), so a hand rename in the sequencer survives;pair()also tries the shot namedshot_name. Before, renaming a built step built a second shot and left the first 'not in doc'.ShotStore.manifest_editskeeps the layers, one per source, saved with the scene. Tests:test_manifest_edits.py,test_shots_manifest_core.py(TestStepRename, pairing),test_shots_core.py. -
2026-10-05 -- A detached launch from a test sandbox gets the host's environment, not the sandbox's:
TestSandbox.host_environ(core_utils/test_sandbox.py,core_utils/app_launcher/_app_launcher.py).AppLauncher.launch(detached=True)handed the app the run's temp root asTEMP/TMP/TMPDIR, which goes when its owner does. A GUI Maya the mayatk runner launched kept the root after the run, the user went on working in it, and another suite's activation swept the root (owner exited): the WebXR preview's FBX was deleted between its export and its GLB conversion ("FBX source not found"). Every guard now records what it redirects (user_config()and uitk's presets root included; a nested redirect of one variable keeps the outer record), and a detached launch gets those variables back as the host had them, on the LIVE process environment (AppLauncher.process_environ), so a DCC host's own variables still reach it. A waited child still shares the root, and a value the caller set inenvstands. Test(s):test_test_sandbox.DetachedLaunchTest. -
2026-10-05 --
ShotStore.claim_hooks(objects)returns(on_replace, on_move)sharing one key resolution (core_utils/engines/shots/shot_model.py).key_edituses it, so a DCC that resolves a curve's owners by scanning the scene pays that once per edit, and nothing for a hook the edit never calls.release_replacednow reads its frames once per call: a generator of frames was used up by the first of several ledger keys, so a curve with two owners released only the first owner's claims. Test(s):TestKeyEdit.test_both_hooks_share_one_key_resolution,TestReleaseReplaced.test_frames_given_as_a_generator_reach_every_owner. -
2026-10-05 -- A truncated or corrupt binary FBX raises
ValueErrorfromFbxMedia(file_utils/mesh_convert/fbx_media.py)._parseaccepted a record whose end came before its start or past the buffer, which raised an unhandledstruct.erroror looped without advancing, and_loadletstruct.errorandIndexErrorthrough although itsRaises:section promisesValueError; a child record ending past its parent parsed as whole, its tail swallowing its siblings, so every rewrite (embed,drop_*, the image passes) wrote those bytes twice. All three now raiseValueError, so a caller that leaves an unreadable FBX as exported can catch it. Test(s):test_fbx_media.EmbedTestCase.test_a_truncated_fbx_is_a_value_error(a missing version, a cut header, a cut record),test_a_child_ending_past_its_parent_is_a_value_error. -
2026-10-05 --
ShotStorecarries shot claims through DCC key edits:release_replaced(also a delete edit'son_delete),remap_moved,key_edit, a purescene_edithook and the_curve_ledger_keyshook (core_utils/engines/shots/shot_model.py). A DCC key edit that landed a key on a claimed one, or deleted a claimed key, left the claims on the frame, where they passed to the arriving key and the next Build deleted that animator key as a dropped behavior; one that moved a claimed key left its claims behind, so the next Build re-created the behavior key beside it.release_replaced(objects)andremap_moved(objects)build the hooks the toolkits' key edits take,on_replace/on_delete(curve, frames)andon_move(curve, pairs). A release drops the claims viaShotEditLedger.release(called withframes=Noneit drops every claim on the curve, for a curve an edit deleted outright); a move carries them viaremap, matching all pairs against the claims as they stood, so two keys that swap frames swap claims. Both act on the ledger the store holds when the hook runs (an undo restore swaps it) and mark the store dirty.key_edit(label, objects)yields(on_replace, on_move)and runs the edit insidescene_edit(one undo step paired with a restore point, which undo restores while the Shot Sequencer panel is open; with it closed the claims are not restored yet) only when the store holds a claim, so a scene with no shots records no restore point; the base class now has a purescene_edithook (it pushes a restore point and yields). A DCC maps a curve to its ledger keys through_curve_ledger_keys(default: the curve is its own key, Maya's anim-curve node name). Test(s):TestReleaseReplaced(incl.test_a_deleted_curve_takes_every_claim_on_it),TestRemapMoved,TestKeyEdit. -
2026-10-05 --
TestSandbox.activated()andrearm()(core_utils/test_sandbox.py).activated()gives every guard to awithblock and puts the launchers, the trash and temp (TMPDIR/TEMP/TMP) back on exit, exceptions included. A guard standing on entry stays standing.rearm()carries the guards over a reload of pythontk: mayatk's in-session harness reloads inside the block, and the reloadedFileUtilshad trashed for real. Test(s):test_test_sandbox.ActivatedTest(test_a_reload_inside_the_block_is_rearmed_and_still_restored). -
2026-10-05 -- Session stores name their owner; what outlives it goes at the next run (
file_utils/temp_artifacts.py). Session tags are<tag>-<pid>, and the first-allocation sweep reclaims an entry whose owner has exited, at any age. 67 test-sandbox roots had piled up in four days, kept for the 7-day age sweep. Unnamed entries keep the age gate. Test(s):test_temp_artifacts.TestSessionOwnership,test_test_sandbox.OrphanRootTest. -
2026-10-05 -- An FBX deliverable can carry every file its scene records name:
FbxMedia.embed_dependencies(file_utils/mesh_convert/fbx_media.py). A lighting-only bake binds its maps to no material, so the FBX shipped none of the lightmaps, probe HDR or shadow maps its records name, and every consumer copied them in by hand.embed_dependencies(src, dst=None, *, search_dirs=(), files=(), logger=None)reads the FBX's own records (DEPENDENCY_FIELDSdeclares which fields name a file), resolves each name in the search dirs and then the FBX's folder, and embeds the file as a bare Video under the record's spelling of the name: no path is written, a second pass changes nothing, ids are stable and the Definitions counts are kept.files=carries extra files (a path, or a(name, path)pair for a host's audio). Unity's importer was measured (2022.3 and 6000.3) to extract that form byte for byte. Primitives:embed,dependencies,named_files, andextract_dependencies(src, dst_dir), which writes the embedded named files back out.downsizeandexpand_grayscalenow touch only material-referenced media. Test(s):test_fbx_media.EmbedTestCase(test_embed_dependencies_resolves_each_named_file_where_the_host_keeps_it,test_embed_dependencies_carries_the_extra_files_a_host_names,test_embedding_again_changes_nothing,test_image_passes_leave_dependency_media_alone,test_named_files_walks_decoded_payloads_by_record,test_extract_dependencies_writes_the_embedded_named_files). -
2026-10-05 -- A GLB built from a delivered FBX finds the maps inside it (
file_utils/mesh_convert/glb/pipeline.py).GlbPipeline.buildlooked for the lightmap and shadow maps only inlightmap_dirsand the GLB's folder, never inside the FBX, so a GLB built from a delivered file elsewhere (a preview of a shipped FBX) came out unlit. It now extracts the FBX's embedded dependency media to a scratch folder (swept when the build returns), searches it first, and its report gainsdependencies. Test(s):test_glb_pipeline.TestEmbeddedDependencies(test_the_fbx_s_own_maps_are_searched_first,test_an_fbx_that_embeds_nothing_adds_no_folder). -
2026-10-05 -- The
fbx_dependenciesgate:ExportVerifier.check_fbx_dependencies(file_utils/mesh_convert/export_verify.py). FAILs, naming them, when a file the FBX's records name is not inside it (a Unity import without its lightmap, a GLB without its lighting); SKIPs when the records name no file or there is no readable FBX. Test(s):test_export_verify.TestFbxDependenciesGate. -
2026-10-05 -- The scene-data record leaves the export folder for a per-user store (
core_utils/engines/scene_export/scene_data_sidecar.py,file_utils/mesh_convert/export_verify.py). The hidden.{stem}.scene_data.json(the exporter's own hierarchy baseline anddata_exportsnapshot, which no consumer of the FBX reads) shipped beside every deliverable. It now lives in<user config root>/pythontk/scene_data/<folder>_<hash of the folder>/(SceneDataSidecarBase.store_dir,STORE_FOLDER): one rule for FBX, GLB-only, USD and versioned series, andmanifest_path_forreturns that path. A record an earlier version left beside a deliverable is still read in place and moves into the store at the nextmigrate_legacyor write (the store copy wins when both exist), andExportVerifierlooks in the store first, then beside. The cost: the history no longer travels with a shared folder, so a verifier run on another machine finds no record and its record-dependent gates SKIP. Test(s):test_scene_data_sidecar.TestNothingBesideTheDeliverable(test_a_written_record_leaves_the_export_folder_empty,test_a_record_left_beside_moves_into_the_store),TestManifestIO.test_a_write_sweeps_the_v2_companions;test_export_verify.TestSidecarInTheStore. -
2026-10-05 -- The hand-off block says the named files are inside the file (
core_utils/engines/scene_export/scene_records.py).HANDOFF_INSTRUCTIONScalled the names "a join token against maps supplied separately"; it now says a delivered file embeds them as media of their own (probe HDR and shadow maps too), as it does every file a record names by file name, and the name joins to that copy or, where the file holds no copy, to one provided with it. The block rides every carrier-bearing write, and only the Scene Exporters and unitytk's delivery embed. Test(s):test_mesh_convert.TestFbxHandoff.test_states_that_the_named_files_are_embedded. -
2026-10-05 --
ExportRun.fbx_media_selfcontainedis retired; setting it warns, removed in 0.15.0 (core_utils/engines/scene_export/export_profile.py). Every FBX deliverable now embeds its media (the exporters pin Embed Media over any preset), so nothing reads the flag; a caller that still sets it gets aDeprecationWarningand a run. Test(s):TestExportRun.test_the_self_contained_flag_is_retired_and_warns_only_when_set. -
2026-10-05 --
TiledPath.with_name/TiledPath.stemandMapFactory.lightmap_for_base(file_utils/tiled_path.py,core_utils/engines/textures/map_factory/_texture_sets.py,_map_factory.py). mayatk's and blendertk's texture renames and name syncs each carried a copy of these three helpers; both now delegate here.with_name: the path-side counterpart ofTiledPath.rename(a stored path naming the renamed file, its folder spelled as it was).stem: the name a host gives a node or image after its texture (no extension, no tile/frame token).lightmap_for_base: a set's baked lightmap spelled for a new base (<base>_Lightmap[_N], the convention's affix).- Tests:
test_tiled_path.py(TestWithName,TestStem),test_map_factory_grouping.py(LightmapForBaseTest).
-
2026-10-05 -- A gap hold now stops on the next shot's start, on every shot op (
core_utils/engines/shots/shot_sequencer.py). The hold steps a gap's last key, and with no key ON the next shot's start the held value ran on to that shot's first key: a no-op respace changed 4 frames of one shot and 6 of the next, by up to 7.9 units, in Blender and Maya (BACKLOG 2026-10-04), because only a respace that changed a gap's width pinned._enforce_gap_holdsnow pins that start through a new_pin_bound_keyshook before writing the hold (claimed in the ledger, never content), and never holds a shot's own start pin (_holdable_seams). Test(s):TestGapHoldPins. -
2026-10-05 --
merge_shotsretires the keeper's end samples in place (core_utils/engines/shots/shot_sequencer.py). The reconcile moved the keeper's end pin onto the merged end, past every key in between (Blender: 84 frames of the curve reshaped). Test(s):TestMergeRetiresItsInnerBounds. -
2026-10-05 --
ShotSequencer.move_stepped_keys: one point-clip rule for both DCCs (core_utils/engines/shots/shot_sequencer.py). A point clip's drag moves exactly the keys its mark was drawn from, stepped or not (_point_clip_curves/_move_point_keyhooks); the DCCs disagreed (BACKLOG 2026-10-04). Test(s):TestPointClipDrag. -
2026-10-05 --
ExportVerifierfails a Shots + Full Sequence deliverable with no whole-timeline clip (file_utils/mesh_convert/export_verify.py). Blender's split shippedbothas the shots alone, and every gate passed whileclip_originwas skipped.clips_vs_takesandfbx_takesnow FAIL when the declared mode isbothand every clip or take is a declared shot. Test(s):test_export_verify.pytest_a_declared_both_export_without_its_whole_timeline_fails,test_shots_only_needs_no_whole_timeline,test_a_declared_both_fbx_with_its_whole_timeline_take_passes. -
2026-10-05 -- OpenCV reads and writes any path:
ImgUtils.cv2_read/cv2_write/cv2_temp(img_utils/_codecs.py,_img_utils.py,_color_space.py,exposure_equalizer.py,image_curator.py,vid_utils/frame_extractor.py). On Windowscv2.imread/imwriteopen a narrow (ANSI) path, so any non-ASCII character -- evenrésumé-- read as None and wrote nothing, and OpenCV's EXR/HDR codecs stage through the temp folder the same way, so a non-ASCII TEMP broke every in-memory EXR encode and decode (measured, opencv-python 4.13): the GLB lightmap/probe embed failed as "Unreadable image",load_image/save_imageof EXR/HDR raised, ExposureEqualizer aborted, ImageCurator kept nothing and FrameExtractor wrote no frames.cv2_read(Python file IO +imdecode) andcv2_write(imencode+ Python file IO) keepimread/imwrite's flags, bytes and None/False contract;cv2_temp(promoted from mayatk's TextureBaker) spells the temp folder in its 8.3 ASCII form for the block, one swap shared by nested and threaded blocks. Every cv2 path read/write in the package goes through them, and a failed 16-bit colour TIFF write raisesOSErrorinstead of reporting success. Tests:Cv2PathIoTest(incl.test_no_module_opens_an_image_path_through_cv2_itself) intest_img.py,FrameExtractorOutputPathTestintest_frame_extractor.py.