github m3trik/pythontk v0.13.1
pythontk v0.13.1

6 hours ago
  • 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 of ShotPairing.rows into 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, else built / locked / missing_shot; not_in_doc for a shot no step pairs with). table lays 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; render writes CSV, JSON (each record whole, less the columns left out) or a Markdown pipe table; clipboard gives a table's TSV + HTML for a spreadsheet paste; write picks 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 (for ManifestExport) and ManifestModel.column_clipboard (now ManifestExport.clipboard of a one-column table) warn through Deprecation, 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.json pins ManifestExport.
  • 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's follow_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.py TestMemberIdentity (+3; red before: no such method).
  • 2026-10-10 -- ShotSequencer.reconcile_system_edits forgets 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.py authored_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.py test_reconcile_forgets_behavior_claims_on_a_deleted_curve (red before).
  • 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.py HeightFieldMap.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.py test_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).
  • 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 = false marches per pixel again; session.cacheStats counts the marches.

    • Test(s): test_shadow_web.py test_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.
  • 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.py TestApplyGlbArticulation.test_a_rig_whose_roots_hang_under_different_nodes_is_refused (red before).
  • 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's build(), 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 of None, and the incremental Build failed with a TypeError; it is zero-length at that frame.

    • Test(s): test_shots_manifest_core.py TestRangeResolver (+2); mayatk test_shot_manifest.py TestIncrementalBuild.test_a_typed_start_alone_builds_at_that_frame (failed before: (300.0, None)).
  • 2026-10-10 -- RectPacker, ImgUtils.pack_atlas_rects and ImgUtils.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.py pack_atlas_rects, atlas_extent(stretch=), uv_axis_scale, atlas_layout_extent). Both DCC lightmap bakers tiled their atlases with compute_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.fit finds the largest UNIFORM scale at which items of natural extents fit (skyline bottom-left with a waste tie-break, bisected) and places them: padding texels around every cell, every padded slot on whole align x align blocks (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. With fold=True (on in pack_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_extent is the density formula the bakers share (surface area over island coverage); its new stretch turns the box to its shape on the surface, at the same area, and ImgUtils.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 by uv_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 in img_utils (not geo_utils/uv) because the atlas helpers import it and geo_utils ranks above img_utils. compute_atlas_layout stays: the bakers fall back to it for a group too big for 4-texel cells.

    • Test(s): test_rect_packer.py (20; the three fold tests and the zero-tolerance one failed before), test_img.py PackAtlasRectsTest (12; test_one_long_item_does_not_starve_the_rest failed before -- 4 px against 16; test_the_tiled_layout_this_replaces_loses_the_gutter_there pins the old layout's lost gutter at 256; test_extent_turns_a_stretched_layout_to_its_surfaces_shape, the uv_axis_scale tests and test_axis_scale_shrugs_off_a_sliver failed before).
  • 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.py PointerChannel.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.py TestApplyGlbFades.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).
  • 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 multiply shadowIntensity into 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).
  • 2026-10-10 -- A gap hold's claim knows the key it stepped (core_utils/engines/shots/shot_ledger.py ShotEditLedger.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_key takes 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 a fixed in side (measured). Claims saved without a value behave as before. claimed_step reads a claim without releasing it (the sequencers' clipboards copy a held key as it was before the hold).

    • Test(s): test_shots_core.py TestEditLedgerStepIdentity (5).
  • 2026-10-10 -- Delete Empty takes every shot with nothing keyed in it (core_utils/engines/shots/shot_model.py ShotStore.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.py TestScopedShotDeletes (+5, 2 rewritten for the new rule).
  • 2026-10-10 -- ShotSequencer.new_shot: one New Shot for every panel (core_utils/engines/shots/shot_sequencer.py). Shot_<n>, the store's initial_shot_length (the Shots window's Initial Length) long, placed by insert_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 / Emissive gain the OpenPBR weight and colour spellings. Subsurface_Scattering is 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) and LOBE_GATES (lobe parameter -> its weight) are the one vocabulary both DCC builders wire OpenPBR from; LOGICAL_CHANNEL_TYPES derives 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 -- while NORMAL_TYPES stays the base-normal precedence; read for encoding it would have sized and compressed a coat normal as colour. The PBR Metallic/Roughness preset 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 / NEUTRAL gain them (each written under its map type's canonical name, so the output classifies as itself; neutral fills in texture terms), NORMAL_CHANNELS turns a coat normal with its island as it does the base normal, and a source may carry scales ({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 when Hero_Coat is anchored by an unambiguous map or the lobe reading's set (Hero) is not -- rock_BaseColor + rock_Coat_Roughness stays one OpenPBR set while a garment Hero_Coat stays 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_maps and the channel packers' output names read their batch that way, while the per-file resolve_map_type / get_base_texture_name keep the lobe reading.

    • Test(s): test_map_factory_grouping.py LobeNamedSetTest (16), test_map_factory.py TestLobeNamedSet (2); test_map_registry_ambiguity.py OpenPBRLobeClassificationTest (6), LobeChannelTableTest (3); test_map_factory.py TestOpenPBRSet (the set stays one set, the base roughness stays the base's, every lobe passes through); test_uv_transfer.py TestOpenPBRLobeChannels (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).
  • 2026-10-10 -- A GLB reads the scene's own data_export carrier before a referenced module's (file_utils/mesh_convert/_sidecar.py _data_export_carrier). An assembly export carries one NS:data_export per 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's CarrierImport applies the same rule.

    • Test(s): test_mesh_convert.py TestDataExportOverlay.test_the_scenes_own_carrier_wins_over_a_referenced_modules (failed before).
  • 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.py ShotPairing.rows / timeline, manifest_engine.py ShotManifest.bind, manifest_edits.py ManifestEdits.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 binding pair reads first; another shot bound to that step loses it; None unbinds). add_step takes the ID a shot added to the manifest keeps (numbered when taken). Bug fixed with it: a shot with no csv_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.py TestShotPairing (+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.py test_an_added_step_can_take_the_id_it_is_given.
  • 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.auto takes its slots in that order (tag1, tag4, tag7, tag2...).

    • Test(s): test_manifest_tags.py test_neighbouring_sections_never_take_neighbouring_hues (failed before: neighbours one slot apart), test_color.py test_contrast_order_uses_every_key_once_far_from_its_neighbour.
  • 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.py TestDescribeKeySelection (4).
  • 2026-10-10 -- The FBX handoff block no longer calls a lightmap record's intensity a 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.py TestFbxHandoff.test_states_that_the_named_files_are_embedded.
  • 2026-10-10 -- Log files are UTF-8: a Scene Exporter run's .log keeps 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 .log was 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 in LoggerExt.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.py TestHooks.test_the_log_file_keeps_the_run_banner_at_every_level, test_logging_mixin.py FileTeeAndBufferTest.test_a_log_file_takes_any_character_a_record_carries (both failed before: UnicodeEncodeError, empty file).
  • 2026-10-09 -- A Scene Exporter run's .log records the run (core_utils/engines/scene_export/scene_exporter.py). Found on a production export: the .log beside the deliverable was 0 bytes. setup_file_logging put its handler on a bare logger named after the class, which neither the exporter nor its task manager logs through (both use self.logger, <module>.SceneExporter); it now sits on self.logger, and close_file_handlers removes only that handler -- a caller's own on the same logger stays.

    • Test(s): test_scene_exporter.py TestHooks.test_the_log_file_records_what_the_run_logs (failed before: empty file); mayatk test_an_early_abort_closes_the_run_log reads the handler off the exporter's logger.
  • 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 failed fbx_dependencies on two lightmaps it did not hold, and shipped three outdated ones. Its lightmap_metadata named 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.scope is the export set (None = the whole scene); a record declared export_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.py TestSnapshot.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: no scope).
  • 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 requires KHR_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 of FbxFile) and the HandoffManifest sidecar 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>_pivot Null 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 (None when 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); a ktx timeout is a RuntimeError for its image, never a whole conversion. ScriptRunner.failure_summary(output), also set as summary on a failed run's RuntimeError: 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.py TestDecodeGlbTextures (4), test_ktx2_encoder.py Ktx2DecodeTest (6), test_script_run.py TestFailureSummary (4).
  • 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 .lnk through the shell's own Unicode IShellLinkW (ctypes, no dependency), stamped with an AppUserModelID when given so the taskbar groups and pins the program under it; on Linux a freedesktop .desktop entry (Exec quoted by the spec, executable for a desktop). Writing one again replaces it. WScript.Shell was 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) is companion_python's console-free twin: the pythonw beside a Windows Python when one exists (a venv, a python.org install), else the interpreter itself (mayapy and Blender's bundled python have none; pip's gui-script launcher swaps to a pythonw without looking, which in Blender's Python names a file that does not exist).

    • Test(s): test_app_launcher.py TestWindowlessPython (4), TestCreateShortcut (5: a .lnk keeping a name and target outside the code page, read back through Shell.Application; replace on rewrite; a .desktop entry's spec quoting; an explicit folder that must exist; macOS refused).
  • 2026-10-09 -- Shot Manifest colour tags: ManifestTags, ShotStore.manifest_tags, the mapping's color_by, and Palette.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 as manifest_edits and read tolerantly): ManifestTags.read / assign (the store is dirtied only by a change). The automatic layer is the template's new color_by field (none | section; its choices ARE ManifestTags.RULES, so a new rule is one registry entry), offered by default.json as 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 names tag1..tag8 -- saved data stores the slot, never a hue.

    • Test(s): test_manifest_tags.py (9); test_shots_core.py test_manifest_tags_read_tolerantly and the store round-trip; test_shots_manifest_core.py test_color_by_offers_exactly_the_tag_rules; test_color.py test_tags_palette_is_eight_distinct_neutral_slots.
  • 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 declares when() (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-key key (one table, one owner per key: a second claim is warned and left unbound), group and order; 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. New viewer.menu(category, {title}) -> {add(kind, spec), setBadge, refresh, open, close, ...}; viewer.window(category, {title, label, key}) now files a window item in its menu (item new; button is 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 (served scene.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 carries parts ([{url, name}]), a guest may read every part, scene.json names them. PreviewDeliverer.PARTS_KEY (a payload's [(path, label)]): each part built as one file is, published together; publish(glb) takes a list and labels; the result gains parts and builds. 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 gains parts (its gltf is 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) (and PreviewBridge.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() gains private. 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) (and PreviewBridge.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).
  • 2026-10-09 -- The naming convention ships a read-only default preset; core_utils/naming_convention.py is now the core_utils/naming_convention/ subpackage. NamingConvention.preset_store() gains a built-in tier (BUILTIN_PRESETS_DIR, the package's presets/) holding default.json -- DEFAULTS as 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, and pythontk.core_utils.naming_convention still resolves NamingConvention, AffixRule and the config constants (lazy_exports); surface snapshot updated. Test(s): test_naming_convention.py ConventionSnapshotTest (+3, one a drift guard holding default.json to DEFAULTS).

  • 2026-10-09 -- X11 writes 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_property checks the root window by default -- how a window manager advertises a feature (KWin's blur effect sets _KDE_NET_WM_BLUR_BEHIND_REGION there). 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_window on 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, or LIGHTMAP_BASIS = None, keeps the lossless container.
    • ImgUtils.resize_image(image, x, y, normal_map=False): normal_map=True resamples 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 by optimize_glb_textures (by slot, in every container -- the slot is now classified in PNG/JPEG mode too), FbxMedia.downsize (by registry filename type) and MapOptimizer (plan flags a normal map's resize/POT ops).
    • Test(s): test_mesh_convert.py TestOptimizeGlbKtx2 (5 lightmap tests replacing 2, +test_a_normal_map_is_resampled_as_one); test_img.py (3); test_map_optimizer.py TestNormalMapResample (2); test_fbx_media.py test_downsize_resamples_a_normal_map_as_one.
  • 2026-10-08 -- ExportProfile.CHECK_DEPENDENCIES drops check_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 .part beside it, promoted by atomic_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.py DeliverFileTest (2).
  • 2026-10-08 -- MeshConvert.fbx_media_dirs(fbx_path) and GlbPipeline.build(shadow_dirs=) (file_utils/mesh_convert/). The folders a file an FBX names loosely is looked for beside it (its own, sourceimages inside and beside it), now one method fbx_to_glb uses; build forwards shadow_dirs, so a host converting a STAGED copy (mayatk / blendertk Scene Exporter) still searches where the file ships.

    • Test(s): test_glb_pipeline.py test_a_staged_build_searches_the_deliverables_folders.
  • 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 its RawVertex::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_glb now 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 rewrites JOINTS_n/WEIGHTS_n where 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.audit is 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 .part with 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 .part and promoted it with a bare FileUtils.replace_file; a refusal left the .part beside 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's fbx_dependencies FAIL) and four full-size .part files (~970 MB) stayed in the folder. FbxMedia._promote removes the .part when 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 new copy_attribute_keys hook (pure default: nothing, no scene). Audio is not copied. A source keeps its content and its members.
    • ShotsEdited(label): every store's scene_edit ends 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 name ShotsEdited (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 had build_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), never needs_build. disabled_behaviors are neither keyed (Behaviors.apply_to_shots(skip_behaviors=), anchors still spread over the whole list) nor released, nor reported by assess, nor re-keyed by reapply_object.
    • Test(s): test_shots_core.py TestShotsEdited (4); test_shots_manifest_core.py TestEditedShotsAreKept (11), TestDisabledBehaviors (3), TestStatusRollup (+1), two existing tests moved to the new contract (a hand rename and a removed member are kept unless rebuild_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 of stale_shots, which takes the shots whose named objects are all gone. shots_in_scope(scope) / remove_shots_in_scope(scope) with DELETE_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; and remove_shot drops the active id when it removes that shot (an ActiveShotChanged) -- it was left naming a shot that no longer existed. changed_since_snapshot(): whether the store differs from its newest restore point -- how mayatk's scene_edit tells 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.py TestScopedShotDeletes (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 (as resize_shot_bounds does) 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.py TestShotEdge, 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 fires MDagMessage matrix callbacks there), where run would deadlock and QTimer.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_env is 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's XformUtils.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_member reads 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). ManifestEdits records 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 -- and apply() composes them over freshly parsed steps without touching them (stale() names the edits a changed sheet gives no place to). BuilderStep.name / shot_name rename a step's shot while step_id stays its binding, and BuilderStep.sheet / BuilderObject.sheet carry the sheet's value of each edited field. A build creates a shot under shot_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_NAME metadata), so a hand rename in the sequencer survives; pair() also tries the shot named shot_name. Before, renaming a built step built a second shot and left the first 'not in doc'. ShotStore.manifest_edits keeps 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 as TEMP/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 in env stands. 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_edit uses 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_replaced now 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 ValueError from FbxMedia (file_utils/mesh_convert/fbx_media.py). _parse accepted a record whose end came before its start or past the buffer, which raised an unhandled struct.error or looped without advancing, and _load let struct.error and IndexError through although its Raises: section promises ValueError; 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 raise ValueError, 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 -- ShotStore carries shot claims through DCC key edits: release_replaced (also a delete edit's on_delete), remap_moved, key_edit, a pure scene_edit hook and the _curve_ledger_keys hook (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) and remap_moved(objects) build the hooks the toolkits' key edits take, on_replace / on_delete(curve, frames) and on_move(curve, pairs). A release drops the claims via ShotEditLedger.release (called with frames=None it drops every claim on the curve, for a curve an edit deleted outright); a move carries them via remap, 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 inside scene_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 pure scene_edit hook (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() and rearm() (core_utils/test_sandbox.py). activated() gives every guard to a with block 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 reloaded FileUtils had 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_FIELDS declares 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, and extract_dependencies(src, dst_dir), which writes the embedded named files back out. downsize and expand_grayscale now 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.build looked for the lightmap and shadow maps only in lightmap_dirs and 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 gains dependencies. 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_dependencies gate: 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 and data_export snapshot, 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, and manifest_path_for returns that path. A record an earlier version left beside a deliverable is still read in place and moves into the store at the next migrate_legacy or write (the store copy wins when both exist), and ExportVerifier looks 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_INSTRUCTIONS called 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_selfcontained is 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 a DeprecationWarning and a run. Test(s): TestExportRun.test_the_self_contained_flag_is_retired_and_warns_only_when_set.

  • 2026-10-05 -- TiledPath.with_name / TiledPath.stem and MapFactory.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 of TiledPath.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_holds now pins that start through a new _pin_bound_keys hook 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_shots retires 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_key hooks); the DCCs disagreed (BACKLOG 2026-10-04). Test(s): TestPointClipDrag.

  • 2026-10-05 -- ExportVerifier fails a Shots + Full Sequence deliverable with no whole-timeline clip (file_utils/mesh_convert/export_verify.py). Blender's split shipped both as the shots alone, and every gate passed while clip_origin was skipped. clips_vs_takes and fbx_takes now FAIL when the declared mode is both and every clip or take is a declared shot. Test(s): test_export_verify.py test_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 Windows cv2.imread/imwrite open a narrow (ANSI) path, so any non-ASCII character -- even ré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_image of EXR/HDR raised, ExposureEqualizer aborted, ImageCurator kept nothing and FrameExtractor wrote no frames. cv2_read (Python file IO + imdecode) and cv2_write (imencode + Python file IO) keep imread/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 raises OSError instead of reporting success. Tests: Cv2PathIoTest (incl. test_no_module_opens_an_image_path_through_cv2_itself) in test_img.py, FrameExtractorOutputPathTest in test_frame_extractor.py.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.