github m3trik/pythontk v0.19.0
pythontk v0.19.0

2 hours ago
  • 2026-11-01 -- Removed: the retired tasks["version"] (the Version pattern), its window closed with this release (core_utils/engines/scene_export/export_profile.py, scene_exporter.py, __init__.py). ExportRun.from_tasks no longer carries it to the exporters to fold with a notice: a non-empty version is an "error" note -- the run stops -- naming the {n} counter that replaces it ('*_v{n:03d}'), never an unversioned write under a name the caller did not ask for; a blank one says nothing. ExportRun.version_format is gone (MOVED_NAMES -> ExportProfile.VERSION_TOKEN), and ExportProfile.fold_legacy_naming takes the RegEx field alone, keyword-only (name_regex=): its version_format and timestamp parameters had no caller left, and a positional caller still passing one is refused rather than read as a regex. mayatk and blendertk stop folding it in this cascade. Tests: test_export_profile -- the version test asserts the refusal and the fold test the removed keywords (both red on the old code), the mode-key test expects no warning. Affected run: 123 modules, 6924 tests, 0 failed, 0 errors, 32 skipped.
  • 2026-11-01 -- Docs: TransferRun's host adapters are handed to it by each library's TextureCombine.run_transfer (geo_utils/uv/transfer_run.py, the root registry's comment). The libraries moved the entry off TextureTransfer.
  • 2026-11-01 -- Quaternion.euler_near(order, euler, previous): the Euler filter for keyed rotation, in every rotate order (math_utils/quaternion.py; file_utils/mesh_convert/_gltf2fbx.py). Of a rotation's two triples and their 360-degree wraps it takes the spelling nearest the previous key, and at gimbal lock -- where only the inner and outer angles' sum or difference is fixed, and a reader puts the whole turn on one of them -- the split nearest the previous key (of those, the one nearest the reader's), so a sweep through the lock keeps its turn on the axis it rides. gltf_to_fbx's xyz-only _euler_near (and its _wrapped_delta) is this rule, so it goes; mayatk's SmartBake keys with it too.
    • Test(s): test_quaternion.py test_euler_near_takes_the_spelling_nearest_the_last_key (moved from test_gltf2fbx.py) and test_euler_near_keeps_a_sweep_through_gimbal_lock_on_its_axis (all six orders, both locks, the turn on either angle, read with the turn on either), red first. Green: the 22 modules affected_tests.py selects, 1714 tests, 1708 passed, 6 skipped.
  • 2026-11-01 -- Fix: gltf_to_fbx keys a rotation through gimbal lock continuously (file_utils/mesh_convert/_gltf2fbx.py _euler_near). At the lock only X - Z (Y 90) or X + Z (Y -90) is fixed, and the Euler reader puts the whole turn on Z: a sweep carrying the turn on X spiked both curves by the whole turn for one frame, right only AT the frame (the reader before 2026-10-31's Quaternion put it on X and spiked the other sweep). A key now takes, of the splits as near the last key as any, the one nearest the reader's; a first key keeps the reader's. A near-gimbal key is pinned too: it keeps X and Z apart (the 2026-10-31 reader; the one before it folded keys within 4.5e-4 rad of the lock, 3.5e-3 degrees off).
    • Test(s): test_gltf2fbx.py new test_a_curve_sweeping_through_gimbal_lock_stays_continuous (turns on X, Z, both; Y 90 and -90; red first, 2 of 4 subtests), test_a_near_gimbal_key_lands_as_the_turn_it_states.
  • 2026-11-01 -- Fix: a file name a record stored on Windows is its file name on POSIX; new FileDependencies.file_name(path) (file_utils/file_dependencies.py, file_collect.py, mesh_convert/fbx_media.py). os.path.basename on POSIX hands C:\tex\wood.png back whole, so FileDependencies.resolve / candidates / find_files / claims / superseded / keep_read_elsewhere and FileCollect.entries searched for a name no file carries. Every name a record or a host stores is read through file_name (either separator); the copies FileCollect and FbxMedia spelled by hand use it too.
    • Test(s): test_file_dependencies.py new test_a_name_stored_on_windows_is_its_file_name_on_posix, test_file_collect.py new test_a_path_stored_on_windows_is_searched_by_its_file_name_on_posix (posixpath.basename patched in; red first).
  • 2026-11-01 -- SceneListing.format_name says when it does not know a case style (file_utils/scene_listing.py). A style StrUtils.CASES does not name left the name as it was with nothing said, and a str method of its name ran ("split" raised on the suffix). It is left, with a warning naming the styles.
    • Test(s): test_scene_listing.py new test_an_unknown_case_style_leaves_the_name_and_says_so (red first).
  • 2026-11-01 -- FileCollect.preview names no panel's button (file_utils/file_collect.py). Its closing line is "Nothing has been written."; tentacle's Collect Textures panel says how to commit the plan it armed.
    • Test(s): test_file_collect.py test_run_previews_writing_nothing_then_commits_the_plan_shown (red first).
  • 2026-11-01 -- The host seams' one convention: EmissiveGroupsHost / PureEmissiveGroupsHost / EmissiveGroupsModel.HOST; every host Protocol is runtime_checkable (core_utils/engines/textures/emissive_groups.py, scene_export/scene_data_sidecar.py, file_utils/file_collect.py, file_utils/workspace_map.py, geo_utils/probe_placement.py, vid_utils/sequence_exporter.py, __init__.py, CLAUDE.md). EmissiveGroupsAdapter and EmissiveGroupsModel.adapter moved (MOVED_NAMES, 0.19.0). PureEmissiveGroupsHost is the model with no scene: every read finds nothing, every write refuses with a RuntimeError naming the binding, and each host's adapter is built on it. SidecarHost, CollectHost, WorkspaceMapHost, ProbeHost, ProbeMesh and SequenceHost are runtime_checkable, so a host's suite checks its adapters with isinstance. ProbeHost keeps no Pure default, and its docstring says why: every answer reads a scene's geometry, and an unbound placement is refused.
    • Test(s): new test_host_seams.py (the roster read off the root: each *Host Protocol runtime-checkable, each with a Pure default that satisfies it, or a reasoned row); test_emissive_groups.py new TestPureHost (2).
  • 2026-11-01 -- One home for the helpers other modules reached privately: new IterUtils.values_at(data, path), FileDependencies.spelled, FbxFile.display_name, FbxFile.take_samplers, ExportProfile.retired_row_notes / retired_row_keys, NodeNames (iter_utils/_iter_utils.py, file_utils/file_dependencies.py, mesh_convert/fbx_file.py, scene_export/export_profile.py, scene_records.py). The payload path walker written twice (scene_records._NodeNames.values, FbxMedia._field_values) is IterUtils.values_at; FileDependencies._spelled, FbxFile._display_name and the two _retired_row_* rules are public under those names; glb/rotations samples a take's curves through take_samplers instead of _take_key_curves / _evaluate_curve; GlbClips._lerp is MathUtils.lerp; export_snapshot builds scene_records.NodeNames. PresetLibrary reads versions through PackageManager.metadata_version and PackageRequirement.release (its own copies gone). Docstrings: ProgressionCurves.centered_offsets (one point is the curve at the centre), StrUtils.to_legal_name (Switchboard.convert_to_legal_name); fbx_media drops a repeated import.
    • Test(s): test_iter.py new test_values_at_walks_keys_and_fans_out_over_lists; test_export_snapshot.py shots fixtures in the record's own shape (clip, integer frames).
  • 2026-11-01 -- Tool logic moved down from tentacle's panels: new TransferRun, TransferResult, TransferHost, PureTransferHost (geo_utils/uv/transfer_run.py); SequenceExporter.default_output_base / split_output_base; Workspace.export_path / export_name; SceneListing.rows (+ SceneListing.Rows); HierarchyAnalyzer.generate_diff_report(max_items_per_category=None).
    • TransferRun is the Texture Combine panel's Transfer, written once for both hosts: which passes a Source / Transfer pair runs (passes), Auto's pick (resolve_auto), the refusals (gate, named in REFUSALS), and run(**rows) -- the UV pass or the texture pass with Overwrite target's report and the lightmap pass, in one undo step -- over a TransferHost adapter (mayatk's / blendertk's TextureTransfer.run hands it theirs; PureTransferHost with no DCC: nothing selected). It returns a TransferResult of plain values the panel words. The refusals are decided before the undo step opens, so a refused run leaves none (Blender pushed an empty one).
    • SequenceExporter.default_output_base(workspace) is a playblast's default output, <movie folder>/<scene name> of the scene's project (the Desktop without one); split_output_base(text, workspace) reads an output field into (directory, name) (blank: the default, a dotted scene name kept whole -- v1.test.ma playblasts as v1.test, where the panel's copy split off .test; a folder: the scene's name; a // path left for the host). Workspace.export_path(scene_path, extension, create=False) is where an export of a scene goes by default, the export folder under export_name (the scene's name, untitled while unsaved).
    • SceneListing.rows(files, workspace, extensions, ...) is a scene browser's table from plain values: the checked types kept by the Naming filters (and how many they left out), the scene's references from elsewhere first under their home workspace, the foreign scenes of a checked type last, each labelled.
    • HierarchyAnalyzer.generate_diff_report takes max_items_per_category=None for the full report a file carries (it used to raise on it).
    • Test(s): new test_uv_transfer_run.py (26: the panel's Transfer cases moved down, the refused run's undo step red first); test_sequence_exporter.py new OutputDefaultTestCase (4), test_workspace.py +1, test_scene_listing.py new TestListingRows (4), test_hierarchy_utils.py +1 -- each red first (errors on the missing API; the dotted name failed in tentacle's test_rendering_mixin). The 17 affected modules (with test_surface_snapshot, test_host_seams, test_module_resolver): 1082 tests, 1081 passed, 1 skipped; after the dotted-name fix test_sequence_exporter + test_uv_transfer_run: 74 passed.
  • 2026-11-01 -- StrUtils.sequence_names: the suffix-by-location plan both DCCs share, written once (str_utils/_str_utils.py, str_utils/_affix.py). Given {key: name} (or plain names) and an order callable, it strips what a previous run left (a trailing number, also after number_separators such as Blender's tile.001; the batch's own run of letters from A; with strip_defined_suffixes a known type suffix, stacked ones one per pass), groups with independent_groups (a kept type suffix moves after the number), and numbers each name in order (StrUtils.sequential_suffixes, whose body moved beside it). mayatk's and blendertk's Naming.append_location_based_suffix each carried a copy of this block and now call it.
    • Test(s): test_str.py five new test_sequence_names_* (order, plain names, number separators, a re-run's own letters vs a side marker, independent groups with a kept or dropped type suffix), red first. Green: the 5 modules affected_tests.py selects, 660 passed, 0 failed.
  • 2026-11-01 -- ShotStore.scene_edit(label, snapshot=True, on_refusal=None) answers a refused edit instead of raising; ShotStore.EditHandle.refused (core_utils/engines/shots/shot_model.py). An edit the planner declines raises ShotBoundaryConflict. With on_refusal the bracket closes the step and settles the restore point by its own rule (a stage that wrote before the refusal keeps the point that undoes it), then sets the refusal on the handle and hands it to on_refusal -- after the step closed, so a panel that redraws there sees the scene as the edit left it. A refused edit says no ShotsEdited; every other error still raises. It is the one bracket every refusable panel edit runs in: tentacle's Shots and Shot Sequencer panels caught the refusal by hand at seven sites, and the copies had drifted.
    • Test(s): test_shots_core.py new TestARefusedEdit (7), red first (6 errors); test_shot_sequencer.py new test_a_shift_edge_stops_a_frame_short_where_the_poses_differ (expectedFailure: a Shift edge closing a gap onto another pose lands flush, BACKLOG 2026-11-01). The 12 affected modules: 1344 passed (1 expected failure).
  • 2026-10-31 - Fix: FbxMedia reads a record off a carrier alone (file_utils/mesh_convert/fbx_media.py _carrier_props). The walk read a record's key off every Model. An artist node's custom string attribute of a record's name, which Maya's FBX exporter writes, therefore read as that record in record, records, dependencies and audio_events. The materials reader (_carried_record) and unitytk's CarrierFile take carriers alone. A Model that SceneStoreBase.is_carrier does not name is now skipped, so all the readers agree. Found by a review of unitytk's CarrierFile.
    • Test(s): test_fbx_media.py new test_a_record_of_its_name_on_another_node_is_no_record (red first). Green: the 7 modules affected_tests.py selects, 1073 passed, 1 skipped. unitytk's reader fixtures read in Unity against this reading (12 passed).
  • 2026-10-31 - Fix: the dependency pass embeds every map the materials record names (file_utils/mesh_convert/fbx_media.py FbxMedia.DEPENDENCY_FIELDS gains a materials row; export_verify.py docstring). The table had no materials row. Maya's FBX exporter embeds only the maps a material's slots carry, and an openPBRSurface reaches the file as a placeholder with none, so the map its entry names reached neither Unity nor a GLB built from the delivered file (measured 2026-10-31 on a Scene Exporter deliverable: the record named op_basecolor.png, which the FBX lacked). The row walks every MaterialFamilies.TEXTURE_FIELDS path, so the pass resolves, dedupes, sizes and reports those maps by the rules the lightmaps and shadows rows follow. A map the material already embeds is left in place. Every reader of the table follows the row too: dependencies(), the verifier's fbx_dependencies gate (a named map missing from the file now fails it), extract_dependencies and drop_host_lighting_media. The GLB applier already reads a map off the file (FbxMedia.extract).
    • Test(s): test_fbx_media.py new test_the_materials_records_maps_ride_in_like_the_other_records (one embedded, one present, one missing, a shared name once) and test_mesh_convert.py new test_a_map_only_the_record_names_reaches_the_glb_built_from_the_file (a GLB built from the file alone binds the map); both red first. Green: the 8 modules affected_tests.py selects, 1082 passed, 1 failed, 1 skipped of 1084; the failure was the new GLB test's exact pixel match against the build's one-code re-encode, which it now tolerates (rerun: TestMaterialsRecordThroughTheConverter 4, EmbedTestCase 31). mayatk's 17 affected modules: 2155 tests, 0 failures, 2 skipped. blendertk's 16: 2525 checks passed. unitytk test_maya_surface_materials_integration 11 passed in fresh batch Unity 6000.3.10f1.
  • 2026-10-31 -- FileCollect.finish_move(pairs, keep=()): a move's take-away for a host that placed its copies itself (file_utils/file_collect.py). Normalize Paths stages its copies under _N variants, not a Collect's folders, so it cannot run apply, and its own removal stranded every reader outside the batch (mayatk, blendertk). finish_move is the take-away alone, run once the readers are repointed with keep every file a reader still reads: a source goes only once its destination holds its whole bytes, never when it IS the destination however spelled, never while keep names it (by file identity, as apply keeps). A tile set goes by its tiles, one source twice goes once; it returns (taken, kept).
    • Test(s): test_file_collect.py TestFinishMove (4). The 5 affected modules: 188 tests, 187 passed, 1 skipped.
  • 2026-10-31 -- Added Quaternion (math_utils/quaternion.py, root-exported): pythontk's rotation math in one convention, and every copy re-pointed at it (geo_utils/articulation/model.py, plan.py; file_utils/mesh_convert/_gltf2fbx.py, _fbx2gltf.py, glb/clips.py, glb/key_reduction.py, glb/reader.py, glb/rotations.py). multiply, conjugate, normalize, rotate, from_axis_angle (an axis index, or a vector), from_euler(order, degrees), to_matrix, from_matrix, matrix_euler(order, m), arc(a, b) (a pair's slerp with its constants once, for many reads) and slerp, on (x, y, z, w) tuples: a (x) b applies b first, 3 x 3 rows for column vectors, Euler degrees in Maya's rotate order ("xyz" applies x first). Gone (all private, no caller outside pythontk): _ArticulationModelInternal._qmul / _qconj / _qnormalize / _qrot / _qaxis / _euler_quat / _qmatrix / _matrix_euler, GlbClips._slerp, _gltf2fbx._quat_matrix3 / _euler_xyz / _slerp, _GlbRotationsInternal._matrix_quat / _euler, and the inline copies in key reduction and the GLB reader. Byte-identical on a 3000-case corpus but for two measured choices: gltf_to_fbx's Euler keys read gimbal lock as the rig model does (its own reader folded keys up to 4.5e-4 rad from gimbal -- 3.4e-2 degrees off, against 1.2e-4 -- and put the turn on X where the rig model puts it on Z), and its slerp is the GLB passes' (float32-identical on 200,000 pairs). A quaternion under 1e-12 long now reads as no turn in gltf_to_fbx and the GLB reader, as it already did in the rig model. ArticulationPlan._decompose keeps Maya's own reading, as it states.
    • Test(s): test_quaternion.py (new, 13: the convention, round trips through every order and Shepperd branch, gimbal lock, the shorter arc, arc reading as slerp bit for bit); test_articulation.py, test_articulation_plan.py re-pointed. The 125 affected suites: 7042 run, 7010 passed, 32 skipped.
  • 2026-10-31 -- FileCollect.set_directory and the flat plan (plan(..., flat=True), plan_nodes(..., flat=True)); fix: a Collect's move lands every reader of one file, keeps every file a reader left on its path reads -- however spelled -- and takes back what a failed row copied (file_utils/file_collect.py). set_directory(nodes, target_dir, mode, allow_missing, copy, kind) is the Texture Path Editor's Set Directory over a CollectHost (mayatk's and blendertk's run it): "rewrite" moves nothing, "copy" / "move" are a flat Collect, and a reader with nothing to bring points at the target's file of its name only when that is there (or allow_missing); it returns the rows to repath, the absent readers and the Collect's report, and the host repaths in its own undo step. A flat plan puts every file directly in the destination, spelled as the caller spells it (a file already there under another spelling stays in place), and a name a different file holds there, or two different files of the plan bring, is BLOCKED -- never settled by order. apply judged rows one at a time, so the first reader's move took a shared source away before the second was compared with it and the second was left on the deleted path (Find & Copy too); every row is judged first now, and a move never drops a file a row that did not land reads, nor one an outside reader names through another spelling (a junction, a mapped drive, the share's UNC path: kept by file identity). A row whose copy fails part-way has what this call copied for it removed -- a set lands whole or not at all -- and a destination folder that cannot be made lands its rows nowhere instead of raising. A claim on a tile no longer blocks a second reader of that same file (a set and one of its tiles). outcome names the originals a move left in place for another reader. StrUtils.strip_any_affix's docstring escapes its \d (a warning on every import).
    • Test(s): test_file_collect.py TestFlatPlan (8), TestApplyGuards (4), TestSetDirectory (5), test_a_move_lands_every_reader_of_one_file, test_a_move_says_which_files_it_copied_for_another_reader; red first (against HEAD: 6 of the guard tests failed, the flat ones raised on flat=). The 10 affected modules: 839 tests, 838 passed, 1 skipped.
  • 2026-10-31 -- Fix: a node scaled flat on one axis keeps its rotation through FBX2glTF (file_utils/mesh_convert/glb/rotations.py new GlbRotations; new MeshConvert.restore_glb_rotations(glb, fbx, fps=None), run in fbx_to_glb's session; MeshConvert.CONVERSION_BAKE_FPS 24 -> 30; fbx_file.py FbxFile.take_keys over a new _take_key_curves). A zero scale leaves the converter's matrix decomposition no rotation, and FBX2glTF 0.13.1 drops what it cannot say (measured): the node's rest rotation when it is flat at rest -- and every rotation channel it has, each frame compared against that NaN rest -- or a take's channel when every frame is flat; the node played unrotated while its FBX twin kept the tilt. The pass reads the FBX's own Lcl Rotation curves back, evaluated between keys (tangents and all: within float precision of the converter's sampling of Maya's tangents), and composes them as the converter does -- each convention measured against its own channel for a node that is not flat: PreRotation (x) R(RotationOrder) (x) PostRotation^-1 with pre/post in XYZ order, all three ignored where RotationActive is off or absent, and the axis turn a root child takes in a non-Y-up file. Each channel shares its take's time input (one animation per take; t = 0 the take's first key truncated to the bake grid), or mints one at the bake rate (--anim-framerate honoured) where the converter left the take none. Written only where an animation does not rotate the node and the rotation leaves the rest pose; a flat node whose name the FBX repeats, with a rotation among its namesakes, is refused with a warning. Self-gating: the FBX is read only for a node flat at rest that states no rotation, or flat at every key of a take (not zero on every axis); curve proxies are never screened. The bake rate was 24 by the docs and measured 30 (a 24 and a 30 fps file both bake at 1/30 s), so the node-frame budget grows 25%.
    • Test(s): test_mesh_convert.py TestNonFiniteKeys.test_a_flat_nodes_tilt_survives_the_converter (expectedFailure removed after it passed unexpectedly; now also pins the tilt's values and the minted grid at bake30 and bake60), new TestFlatNodeRotations (3: two takes each given only the channel the converter dropped, its own left byte for byte and a second run a no-op; rotation order, pre/post rotation and the Z-up axis turn against round twins the converter wrote; a file with no flat node lacking a rotation, or only a flat curve proxy, never reads its FBX); test_the_conversion_timeout_also_scales_with_the_bake at 30 fps; test_fbx_media.py build_curve_fbx takes poses, parents, axes. Mutants caught: pre/post in the node's order, no axis turn, no take offset. The 20 affected suites: 1633 run, 1627 passed, 6 skipped.
  • 2026-10-31 -- Fix: a Collect's move never takes a file a reader outside the plan reads, and compares every moved file whole (file_utils/file_collect.py new CollectHost.read_elsewhere, FileCollect.plan_nodes, FileCollect.kept_files, FileCollect.source_files, FileCollect.plan(..., keep=)). A local and a linked image -- or a scoped and an Arnold-only file node -- reading one file: the move of the one in scope left the other on a deleted path. The host names the files its other readers read (read_elsewhere; PureCollectHost: none), and plan decides per FILE what a move keeps: every row of a file one row keeps keeps it too, so what a view of the plan shows and what apply does are one decision (kept_files). In a move each landed file is compared whole before its source goes, so a destination that appeared after the copier's own (partial-hash) test is never taken for it; a cancel says how many files were already copied, and run reports it.
    • Test(s): test_file_collect.py test_a_file_a_reader_outside_the_plan_reads_stays_on_a_move, test_what_a_move_keeps_is_decided_per_file_in_the_plan, test_a_move_takes_no_source_for_a_file_that_appeared_after_the_copier_looked, test_a_cancel_says_what_was_already_copied (red first).
  • 2026-10-31 -- Fix: the audio record's digests describe only bytes the file embeds, and the FBX dependency pass writes them (file_utils/mesh_convert/fbx_media.py FbxMedia.embed_dependencies, new _digest_audio; audio_utils/_audio_utils.py AudioUtils.media_digests(events, files) now over {file name: bytes}; audio_utils/audio_record.py; core_utils/engines/scene_export/scene_records.py SceneRecords.AUDIO assembly; DELIVERABLE_FORMAT.md regenerated). The producers digested each track's source file, so a write with no dependency pass -- a raw File > Export, the Game Exporter -- named audio it never embedded, and Unity bound nothing where its by-name lookup had bound the library clip (review, 2026-10-31). The pass that embeds now sets media from the audio files the FBX carries (by the event name that is the file's stem, ignoring case; a stem two audio files share gets none) and drops a digest for a file no longer inside; a write without it carries none and reads as one from before the digests. media_digests takes the bytes, never a path.
    • Test(s): test_fbx_media.py +4 (test_embed_dependencies_digests_the_audio_it_embeds_into_the_record, test_a_digest_is_of_the_bytes_inside_not_the_file_handed_over, test_a_digest_for_a_file_no_longer_inside_is_pruned, test_digesting_again_writes_nothing); test_audio_utils.py TestMediaDigests rewritten (4; the vacuous test_alike_bytes_digest_alike_wherever_they_lie removed); test_audio_record_shape.py (the producers leave media to the pass). Red first: 1 failed, 5 errors. The 33 affected suites: 2638 run, 2628 passed, 10 skipped; sync_scene_records.py --check in sync.
  • 2026-10-31 -- HandoffBridge.scope_objects(scope): every hand-off resolves the Scope words through its host hooks, and a bridge with nowhere to deliver refuses before it exports (core_utils/handoff/app_handoff.py, net_utils/preview/bridge.py). scope_objects moved down from PreviewBridge, which inherits it unchanged, so a panel that sends a host's scope through any bridge asks it the one question (the Unity Workflow app's Current Scene source, over mayatk's and blendertk's UnityBridge). _preflight now refuses a send with no deliverer for its mode and no _deliver of its own, naming what is missing, where it used to export and then raise NotImplementedError: a bridge whose caller attaches the delivery (deliverer=) says so before the export is paid for.
    • Test(s): test_bridge.py HandoffScopeObjectsTest (3), HandoffWithoutDeliveryTest (3), red first.
  • 2026-10-31 -- Removed the retired GLB material writers MeshConvert.set_glb_base_color, set_glb_emissive, set_glb_metallic_roughness, set_glb_alpha_mode and set_glb_unlit (MOVED_NAMES rows naming MeshConvert.apply_glb_materials) (file_utils/mesh_convert/_mesh_convert.py, _materials.py). They applied the scene sidecar's material sections, which went earlier today, and nothing called them: apply_glb_materials states each material as the FBX's materials record does, through the same GlbEdit session. set_glb_normal_scale (the preview's normals dial) stays.
    • Test(s): test_mesh_convert.py TestGlbEditSession re-pointed at apply_glb_materials records (one write per session, with set_glb_normal_scale as the second applier; the JSON chunk rewritten in place and the BIN kept byte for byte; trailing chunks kept; embeds de-duplicated by path, by content and against the converter's own bytes; one ORM pack for shared maps; the displaced converted ORM pruned), plus the growing JSON-only, unreadable-TIFF and TGA re-encode cases from test_preview_server.py SetGlbEmissiveTestCase (removed) and its non-GLB case in TestGlbReadGuards; TestMaterialsRecord test_a_mask_states_its_cutoff, test_any_other_mode_drops_a_stale_cutoff; the writers' own tests removed (TestBaseColorRecordHonesty, TestSetGlbAlphaMode, TestSetGlbUnlit, TestSetGlbBaseColorAlpha and the ORM writer's three).
  • 2026-10-31 -- The audio record carries each embedded track file's content digest, so a same-named clip elsewhere stands in for a track only when it is the same file (audio_utils/audio_record.py new optional AudioRecord.media; audio_utils/_audio_utils.py new AudioUtils.media_digests; core_utils/engines/scene_export/scene_records.py SceneRecords.AUDIO assembly; DELIVERABLE_FORMAT.md regenerated). An event names its track and Unity finds the clip by that name, so two models each embedding a different door.wav bound one clip: the second's import found the first's, skipped its own extraction and bound it (BACKLOG 2026-10-19; decided 2026-10-31: match by content). media is {track name: SHA-256 of the file's bytes, lowercase hex} for each track file the deliverable embeds, computed once here from the host's track_files -- the very answer the FBX dependency pass embeds byte for byte (FileUtils.get_file_info(..., "hash", hash_algo="sha256")). Optional, with no version bump: a reader reads one version alone, and a file written before the digests must still read, so Unity can extract its own clips from it.
    • Test(s): test_audio_utils.py new TestMediaDigests (4); test_audio_record_shape.py new test_each_played_track_file_rides_by_its_name_as_its_digest, test_a_record_written_before_the_digests_still_reads, two media refusals and the schema's optional media, the producers' composition writing it (red first: 2 failed, 10 errors). The 33 affected suites: 2635 run, 2625 passed, 10 skipped; sync_scene_records.py --check in sync.
  • 2026-10-31 -- Added FileCollect, CollectHost and PureCollectHost (file_utils/file_collect.py), and FileDependencies.candidates / choose / on_disk / same_content / keep_read_elsewhere; FileDependencies.resolve(..., key=) and its records' candidates; FileDependencies.relocate is a Collect (file_utils/file_dependencies.py). One rule for every lookup by file name: the search folders and the walk are one candidate set, one file reached two ways or identical copies are one candidate, and several different files leave a record AMBIGUOUS with its candidates -- never the first search folder's (a stale namesake was healed in for good) nor the newest (relocate, Find & Copy). A Collect gathers the files a set of readers use into one folder keyed by READER: names never change; a file goes flat unless a different file of its name is there or gathered with it, then under the shortest run of its own parent folders that tells them apart (dest/wood/diffuse.png, dest/metal/diffuse.png); identical files share one copy, a file already under the destination stays, a tile set is planned and moved whole, and a move takes a source away only once its copy landed -- a file the copier reused counts only when its bytes are the source's, a file any reader keeps (keep_source) is never taken, and a cancel stops before anything is. A pick names its row by FileCollect.row_key ((kind, owner)). FileCollect.run reports a plan or commits it over a host (stored_path, resolve_path, stored_form, repath). relocate's result gains ambiguous, blocked and plan, a record's keep_source copies it in a move, and its copier is handed "copy" and a progress= callback (copy_files(..., progress=)); find_files matches a tile set by its tiles.
    • Test(s): test_file_collect.py (new, 29); test_file_dependencies.py TestChoose (new), TestResolve test_a_search_folder_hit_beside_another_under_the_walk_is_ambiguous, test_one_file_reached_by_the_folder_and_the_walk_is_one_candidate, test_identical_copies_of_a_name_are_one_candidate, test_a_key_keeps_two_same_named_readers_apart, test_a_tile_set_resolves_by_its_tiles, test_find_files_matches_a_set_by_its_tiles, TestRelocate test_plan_then_copy_an_ambiguous_name_is_never_guessed, test_a_same_named_file_already_there_keeps_the_newcomer_apart (red first).
  • 2026-10-31 -- Fix: a hand-off ships a stored export-scoped record that lists no entry as stored, and Blender's USD writer states its unit (core_utils/engines/scene_export/export_snapshot.py). The narrowing cleared a stored record with no entry list, which names no node to narrow by (measured: the WebXR preview's carrier lost its lightmaps record). ExportContext.WRITER_UNITS gains ("blender", "usd"): one BU a metre, measured 2026-10-31 under every convert_scene_units (a 1 BU = 1 cm scene's node at 100 BU is 100 m in the stage, its metersPerUnit and root scale agreeing).
    • Test(s): test_export_snapshot.py test_a_stored_copy_that_lists_no_entry_ships_as_stored, TestWriterUnits; test_mesh_convert.py (the conversion report carries no sidecar outcome; the fade-clone ordering test re-pointed at the materials record).
  • 2026-10-31 -- Added FbxMedia.state_materials(src, dst=None), FbxMedia.legacy_material_values(entry) and FbxMedia.LEGACY_MATERIAL_ROWS, run by MeshConvert.finish_fbx_deliverable (file_utils/mesh_convert/fbx_media.py, _mesh_convert.py). Each materials record entry is written onto its FBX material's legacy properties (DiffuseColor x DiffuseFactor, EmissiveColor x EmissiveFactor, the alpha as Maya writes a transparent Lambert), so an engine's stock FBX import sees the colour the record states: an openPBRSurface reached the FBX as a grey Lambert placeholder and Unity showed it grey (measured 2026-10-05). A post-write pass over the file; the scene is not touched, and the legacy family reads the stated material back as its entry.
    • Test(s): test_fbx_media.py StateMaterialsTestCase (new).
  • 2026-10-31 -- Removed the scene sidecar's material sections: MeshConvert.apply_scene_sidecar, embed_scene_sidecar, sidecar_foreign_packings and SIDECAR_APPLIERS (MOVED_NAMES rows), and the section branch of fbx_to_glb (file_utils/mesh_convert/); the opacity rule and the transmission note are the model's (MaterialFamilies.opacity_scalar, TRANSMISSION_NOTE, the pbr family's transmission). The FBX's materials record states every material, and no host sends a section: an older host's envelope still gives a GLB its handoff, and its sections are read no more (the build reports none). An opacity colour reads as its channels' mean in one place, and a glass's note is written once, for every host and the record-less FBX read.
    • Test(s): test_material_record.py test_an_opacity_colour_reads_as_its_channels_mean, test_a_transmission_is_said_not_stated; test_mesh_convert.py TestSceneSidecar (the section tests removed, test_an_envelopes_sections_are_read_no_more), TestSuspectOrmMaterials (the warning's two tests on the build's check); test_preview_server.py (the section tests removed).
  • 2026-10-31 -- Added PackageRequirement: a pip requirement read and compared without packaging; added PackageManager.metadata_version(name) (core_utils/package_requirement.py, core_utils/package_manager.py). PackageRequirement("extapps>=0.8.0,<0.9").contains(version) answers what pip asks of an installed version: a PEP 508 / PEP 440 subset (a name, [extras], comma-joined >= > <= < == != ~=, a marker kept as text), release segments padded (1.0 == 1.0.0), dev < pre < release < post, and PEP 440's exclusive bounds (<0.9 admits no 0.9.0rc1, >1.7 no 1.7.post1). version_key and release expose the ordering; declared_by(dist, name, extra=) reads the row a distribution's metadata declares, minus the marker that selected it (pip would evaluate it and install nothing). A URL, ===, a wildcard or a local label in a specifier raises ValueError. metadata_version reads the running interpreter's installed version with no pip show subprocess. uitk's version-aware install-on-demand reads ranges through it, as packaging is not guaranteed inside a DCC interpreter. Fix: PackageManager.pip split a command string on commas too, so install("extapps>=0.8.0,<0.9") reached pip as two arguments (<0.9 is no requirement); it splits on whitespace only.
    • Test(s): test_package_requirement.py (20, red first), test_package_manager.py MetadataVersionTests (3) + test_parse_command_keeps_a_specifier_set_whole, test_install_and_update_hand_pip_a_range_as_one_requirement (red first).
  • 2026-10-31 -- The writer states its unit: ExportContext.unit_scale, ExportContext.WRITER_UNITS, ExportContext.writer_unit(host, writer, scene_unit, options) and ExportContext.unit(scene_unit) (core_utils/engines/scene_export/export_snapshot.py). Each writer's metres per scene unit is one row of one table both hosts reach (Maya's FBX and USD: the scene's; Blender's FBX: scale_length with apply_unit_scale on, times global_scale; Blender's native glTF: one BU a metre). The host's export context carries it, and every producer labels its lengths with it and converts nothing; with no writer stated, the label is the scene's unit. A record labelled with the scene's unit beside geometry its writer wrote in another misread by that factor.
    • Test(s): test_export_snapshot.py TestWriterUnits.
  • 2026-10-31 -- Every export writes data for the objects it ships: ExportContext.nodes, ExportContext.ships / narrowed, RecordSpec.nodes and RecordSpec.narrowed (core_utils/engines/scene_export/). The host names the nodes a write ships once; the snapshot narrows every export-scoped record by them -- the producer's copy, and on a hand-off the authored ones it ships as stored (the lightmaps, the shadow planes, the articulated rigs), committed narrowed and put back whole by the exporter's restore. A hand-off shipped every object's bake and the post-write embed their maps (measured: the Unity bridge embedded another cube's lightmap and shadow texture). articulation, emissive_groups and render_effects are export-scoped; emissive_groups is derived, so a hand-off runs its producer, which keeps the groups with a member on a shipped mesh. Shots and audio ship whole. FbxMedia.NODE_ENTRIES moved to RecordSpec.nodes (a MOVED_NAMES row); FbxMedia.reader_records narrows a module's copy through it.
    • Test(s): test_export_snapshot.py (new), test_emissive_groups.py test_a_scoped_write_names_the_groups_with_a_shipped_member.
  • 2026-10-31 -- Added StrUtils.to_identifier(name, replacement="_") (str_utils/_str_utils.py). Letters, digits and _, not starting with a digit: each other character becomes _ one for one, and a leading digit gets a _ in front (1box -> _1box) where Maya's own rename drops it (box, measured). The Naming engines legalize every planned name through it.
    • Test(s): test_str.py test_to_identifier (red first).
  • 2026-10-31 -- StrUtils.set_case takes upper_first and upper_initials (str_utils/_str_utils.py, CASES). Python's capitalize / title lower every other letter, so a name lost its camelCase and side markers (myBigBox -> Mybigbox, arm_L_ctrl -> Arm_l_ctrl). upper_first raises the first letter and upper_initials each word's first letter, keeping the rest (MyBigBox, Arm_L_Ctrl). capitalize and title keep Python's meaning, because saved settings name them (the Shot Manifest's object_case, the Reference Manager's case styles).
    • Test(s): test_str.py test_upper_first_and_upper_initials_keep_the_rest (red first).
  • 2026-10-31 -- StrUtils.strip_any_affix(..., numbered=True) strips an affix a number trails (str_utils/_affix.py). A duplicate's uniquifier or an index after the affix hid it from the anchored match, so a type pass decorated it a second time (Maya's Ctrl+D of crate_GEO is crate_GEO1, which came out crate_GEO1_GEO; wall_GEO_01 came out wall_GEO_01_GEO). With numbered a trailing _?\d+ or Blender .001 is peeled, the affix stripped, and the number kept on the base: crate1, wall_01, crate.001; a base already ending in a digit keeps the numbers apart (wall_01_GEO1 -> wall_01_1). Off by default: body_GEO_02 still stays whole.
    • Test(s): test_affix_hardening.py StripAnyAffixTest.test_numbered_strips_an_affix_a_number_trails (red first).
  • 2026-10-31 -- RenamePlan.apply(park=...) runs a batch in two phases; added RenamePlan.predict(plan, scopes, names, uniquify) (file_utils/file_naming.py). With park, every changing item is moved onto a placeholder first, so no item meets a name another item still holds: a swap, a cycle or a renumbering comes out as planned in any order (Maya measured obj1 -> obj2, obj2 -> obj1 as obj3, obj1). A park that raises reports the item and leaves it as it was; a parked item whose rename raises gets its old name back, so nothing is stranded (a locked node in Suffix By Location's batch had raised and left the others as p0000000000). predict returns the names a uniquifying host assigns to such a run, so a dry run previews box, box1, box2 instead of box, box, box; the host's name pools and its rule for a taken name are callables.
    • Test(s): test_file_naming.py TestRenamePlan (+7: the swap, a locked item, a failed rename unparked, no park on a dry run, three predict cases; red first).
  • 2026-10-31 -- Added PointCloud.order_points(points, order_by="auto", reference, tolerance=1e-3, reverse), PointCloud.spread_axis and PointCloud.ORDERS (geo_utils/pointcloud.py). Keyed positions [(key, (x, y, z))] in a deterministic order: "x" / "y" / "z" by signed position (the two sides of the origin never tie), "auto" along the axis the points are most spread along, "distance" from reference. Values are compared on a tolerance grid, so float noise cannot decide a tie (rotated copies measured 3.975473492743022 vs 3.9754734927430215); a remaining tie goes to the other axes, then the key in natural order, never the input order. Both hosts' Suffix By Location order through it.
    • Test(s): test_pointcloud.py TestOrderPoints (+10, red first).
  • 2026-10-31 -- Added FbxMedia.repair_null_normals(src, dst=None): the zero-length normals, binormals and tangents a written FBX carries are filled (file_utils/mesh_convert/fbx_media.py). Maya writes a (0, 0, 0) normal for each corner of a zero-area face (an extrude never moved), and Substance Painter's FBX reader (Assimp) then drops that mesh's whole normal layer and recomputes smooth normals, every hard edge lost (reported 2026-10-31; Painter's USD reader keeps them). Each null vector takes another corner's on the same vertex (a ByPolygonVertex / Direct layer), else a unit vector -- a zero-area face draws nothing. Returns {mesh: vectors filled}; a file with none is not written.
    • Test(s): test_fbx_media.py RepairNullNormalsTestCase (a zero-area quad's corners borrow their vertex's normal or get a unit one, the valid ones unchanged; a clean file is not rewritten).
  • 2026-10-31 -- Added FileUtils.import_files(paths, folder, mode, on_conflict, validate, strict) (file_utils/_file_utils.py). It brings files into a project folder: copied atomically, moved safely, or linked in place. A file already under the folder is used in place; a validator gates each file; a same-named file is reused when valid, overwritten, or handed to a function that can cancel; strict stops at the first refusal. Each file left out is logged with why, and a missing folder is refused in words a panel can show. Returns {added, skipped, io, failed, cancelled}.
    • Test(s): test_file.py TestImportFiles (red first for the logging and the wording).
  • 2026-10-31 -- Added SceneListing (file_utils/scene_listing.py): the Reference Manager's listing and Naming rules over a project's Workspace. save_target, default_save_name, normalize_structure_pattern and the listing rules, which tentacle's panel held.
    • Test(s): test_scene_listing.py (new).
  • 2026-10-31 -- PresetStore(info=False) and PresetStore.delete(name, trash=True); LookSession.mix_dir / mixes (core_utils/presets/store.py, engines/lighting/look_match/session.py). A store over a folder that is not a preset collection writes no per-preset .preset sidecar; delete(trash=True) sends a user preset to the platform trash. A session's saved mixes are a sidecar-free PresetStore, one <name>.json each in LightMix.save's format.
    • Test(s): test_preset_store.py, test_look_session.py (the mixes folder holds the mixes alone, red first).
  • 2026-10-31 -- ShotStore.save_user_prefs (was _save_user_prefs) and ShotSequencer.source_shot_id (was _source_shot_id_for) are public; added ShotSequencer.keyed_transforms(start, end, **kwargs) (engines/shots/). tentacle's panels read them; keyed_transforms reads the private host hook _find_keyed_transforms each host overrides.
    • Test(s): test_shot_sequencer.py, test_shots_core.py.
  • 2026-10-29 -- PreviewServer is a facade over private collaborators, not four single-use mixins (net_utils/preview/). Every public name, signature and docstring is unchanged. _PreviewShare alone owns the share's security state (guest listener, admitted names, guests, tunnel, private segment, share lock and generation); _ServeRoot, _PageOutputs and _SceneDescription own theirs. The class constants have one owner, the facade's class body, read at call time; private names other code reads forward to their owner. Ending a share now closes the guest listener before it clears the private segment (before, while the tunnel stopped, the open listener answered / and /manifest.json with no segment under the public name), and a closing listener answers nothing, kept-alive connections included.
    • Test(s): test_preview_server.py (two teardown tests red first; the private-share test's "wrong segment" was the token itself 1 time in 64).
  • 2026-10-29 -- Generic names leave the root; reload_package is retired (__init__.py). Kind / Merge are SceneRecords.Kind / .Merge, Op is MapOptimizer.Op, reload_package is ModuleReloader().reload (MOVED_NAMES). Call / Result are RpcJob.Call / .Result behind a deprecated alias (remove_in 0.20.0), because the released extapps imports them.
    • Test(s): test_module_resolver.py, test_surface_snapshot.py.
  • 2026-10-29 -- The Scene Exporter contract knows no Qt (engines/scene_export/). ExportProfile.read_values, .value_method and .CHECKBOX move to tentacle's SceneExporterSlots (MOVED_NAMES). The Output Format row is declared in SceneExporterBase.SETTINGS_DEFINITIONS; its objectName is the saved cmb004.
    • Test(s): test_export_profile.py, test_scene_exporter.py.
  • 2026-10-29 -- Library code reports through class loggers and raises (MapOptimizer, the map converters, ImgUtils, Metadata). Each map's outcome (skip, resize, saved, batch count, POT resize, PBR conversion) logs at RESULT, which the default handler shows; detail stays at info/debug. ImgUtils.is_image_constant raises on an unreadable image; get_image_info names and skips one. Metadata raises on a failed sidecar write and on Windows without pywin32. UserConfig.ROOT_NAME and UserConfig.CONFIG_ROOT_ENV_VAR are class data; the saved location is unchanged.
    • Test(s): test_map_optimizer.py TestReportShownByDefault, test_img.py, test_map_factory.py, test_metadata.py (red first).
  • 2026-10-31 -- Fix: fbx_to_glb no longer ships a NaN animation key (file_utils/mesh_convert/). FBX2glTF decomposes each node's baked matrix, and a zero-scale frame came out a NaN rotation (repo fixture maya_tangents.fbx). The new MeshConvert.fix_glb_nonfinite_keys, run first on every conversion: a non-finite key time takes its place in the keys' spacing; a non-finite value is interpolated from its finite neighbours by time (a slerp for a rotation, a lerp otherwise) and held from the nearest finite key at an end, a cubic-spline key getting flat tangents; it warns once per repaired output, naming its animations and channels and the key count, and drops (with a warning) a channel with no finite value. gltf_to_fbx now refuses a non-finite key time or value, and key times that do not increase, with a ValueError naming the node and channel, instead of crashing with "cannot convert float NaN to integer".
    • Test(s): test_mesh_convert.py::TestNonFiniteKeys 10 + 1 expected failure (the converter's own flat-node drop, ledgered), test_gltf2fbx.py 4 refusals; all red first.
  • 2026-10-29 -- Host-free rules both DCC packages wrote twice, written once (engines/scene_export/, file_utils/file_dependencies.py, math_utils/progression.py). Eleven new public names: BakeBaseline.keep_baked, BakeBaseline.KEPT_ON_REPACK, BakeBaseline.atlas_files, BakeBaseline.AtlasFiles (with splits), FileDependencies.strangers, SceneRecords.resolve_row, SceneRecords.resolved_producers, SceneRecords.resolved_stagers (each takes the host's log=), SceneRecords.peek_entry, SceneRecords.pop_entry, ProgressionCurves.centered_offsets. mayatk and blendertk delegate with their signatures unchanged.
    • Test(s): test_bake_baseline +8 (the saved JSON pinned against the old host code), test_file_dependencies +2, test_scene_record_rows (new, 8), test_progression +1.
  • 2026-10-29 -- SequenceExporter reaches its host through SequenceHost (vid_utils/sequence_exporter.py). The image-sequence plan and its ffmpeg encode are written once over SequenceEncoder.HOST (a SequenceHost: frame range, sequence name and fps, the armed audio, path resolution, frame/still/movie capture, a host-only target kind, notices) with PureSequenceHost's defaults; mayatk's and blendertk's PlayblastExporter each bind their adapter instead of overriding the base. frame_padding is the image capture's own override: it never reaches a host's movie, still or own-kind call.
    • Test(s): test_sequence_exporter.py (fake-host seam, the padding rule red first).
  • 2026-10-29 -- The key stash is one store over a host adapter (core_utils/engines/key_stash). ptk.KeyStash gains HOST (a KeyStashHost: persistence, scene clock, flush, undo chunk, in-step write, reconcile, script jobs, per-store state, keys_keep_time) over PureKeyStashHost, and the bodies both DCCs wrote twice: watch_undo, merge_carrier, reconcile, the host backend active() installs, and the frame-rate rule (rescale where keys keep time, record the rate otherwise). ATTR_NAME and UNDO_EVENTS move onto it. KeyStashConformance pins the saved key_stash record byte for byte. EmissiveGroupsAdapter.faces: an empty map is no faces on every host.
    • Test(s): test_key_stash_core.py (fake-host seam, conformance pin, per-store state).
  • 2026-10-29 -- The README and the Look Match guide name tentacle as the panels' home (docs/README.md, docs/look_match.md). The core_utils row lists all seven shared engines; CLAUDE.md states the host-adapter rule.
    • Test(s): none (docs).
  • 2026-10-29 -- MeshConvert is a facade over private collaborators (file_utils/mesh_convert/). Each job's former mixin (_fbx2gltf, _sidecar, _lightmaps, _highlights, ...) is a part the facade builds on the class it is read through and delegates to; gltf_to_fbx runs on its builder, _GltfToFbx. The facade holds the whole public surface (signatures, docstrings, constants and retired names). A part reaches every name the facade exposes through it, so subclasses, class-level overrides and mock.patch.object(MeshConvert, ...) reach every internal call, an FBX_FAMILIES dialect row whose method a subclass adds included. No public change; the private bases leave the class (MeshConvert.__bases__ == (HelpMixin,)).
    • Test(s): test_mesh_convert.py TestFacadeComposition (the subclass dialect row, red first; every delegation's signature and forwarding pinned to its part's).
  • 2026-10-28 -- One shot store on every host: ShotStore runs on an injected ShotsHost (core_utils/engines/shots/shot_model.py, shot_conformance.py). New root exports ShotsHost, PureShotsHost and ShotTransferHost (the hand-off codec ShotsHost.transfer() returns). A store takes host=; ShotStore.set_host, register_host(name, resolve) and running_host() inject or resolve the process's host; an adapter injected from a registered library stays that library's, so re-importing the library drops it and the store bound to it, after flushing that store's unsaved edits. A store made before any host registered moves to the first one, keeping its frames, taking that host's frame rate and dropping its pure restore points. Written once on the store: scene_edit's restore-point bookkeeping over the host's undo_step and native-step crossing; detect_regions, publish_export_view and the persistence active() installs; the hand-off (export_transfer, apply_transfer, transfer_out, transfer_in, merge_carrier, discard_carrier); follow_renames, the frame-rate rule and the prefs-file apply. ShotStoreConformance is the saved record's golden case; payloads are unchanged.
    • Test(s): test_shots_core.py (fake-host seam, restore-point settling, re-registration, rehome), test_animation_record_shape.py, test_shots_manifest_core.py.
  • 2026-10-28 -- Host behavior is injected, not overridden (InstancingStrategy, SceneDataSidecarBase, HierarchyBaselineStore, SceneExporterBase, ProbePlacement). InstancingStrategy(config, count_triangles=) (TriangleCounter; a failed read counts 0); SceneDataSidecarBase.HOST with the new SidecarHost Protocol and PureSidecarHost (expand_to_descendants, drop_intermediate, build_clean_path_set are classmethods through it); HierarchyBaselineStore._close delegates to its SIDECAR._close; SceneExporterBase.STORE (the saved scene path is STORE.scene_path()); ProbePlacement(baked, unbaked, host=) or one adapter per placement from HOST_CLASS, with the new ProbeHost and ProbeMesh, and ProbeRays(mesh). The ProbePlacement.UP / INTERNAL_M class attributes are gone (the instance's up and INTERNAL_M come from the host).
    • Test(s): test_instancing_strategy.py, test_scene_data_sidecar.py, test_hierarchy_baseline.py, test_scene_exporter.py, test_probe_placement.py (each over a fake host).
  • 2026-10-28 -- CurtainDrape is written once, in pythontk (geo_utils/curtain_drape.py). The pleated, catenary-sagged drape over RailSurface was a vendored twin in mayatk and blendertk; both hosts' CurtainMesh now inherit ptk.CurtainDrape and its constructor. Same contract and output (the grid compared point for point).
    • Test(s): test_curtain_drape.py (moved from mayatk).
  • 2026-10-28 -- New WorkspaceMapBase, WorkspaceMapHost, PureWorkspaceMapHost (file_utils/workspace_map.py). The Workspace Map's scan, measure and tree, host-free behind a host adapter. A root must be an existing absolute folder; no root marks nothing.
    • Test(s): test_workspace_map.py.
  • 2026-10-28 -- ShotStore.EditHandle (core_utils/engines/shots/shot_model.py). What scene_edit yields: cancel() declares the body a no-op, so the edit leaves no restore point and says no ShotsEdited -- one idiom for a panel both hosts share.
    • Test(s): test_shots_core.py +1.
  • 2026-10-28 -- ShotReport words the shot-removal question both shots panels ask (core_utils/engines/shots/shot_report.py). New SCOPE_LABELS ((label, scope) rows in ShotStore.DELETE_SCOPES order), SCOPE_CLAUSES, LISTED_MAX, scope_label(scope), scope_count(shots, scope) and removal_question(shots, scope) -> (title, text); the Shots panel and the Sequencer's shot menu no longer reach into each other for it.
    • Test(s): test_shot_report.py (TestDeleteScopes).
  • 2026-10-28 -- SceneExporterBase is composed by the shared Scene Exporter panel, not subclassed (core_utils/engines/scene_export/scene_exporter.py, export_profile.py). What the two host panels reached for as private members is public: definition_tables, note_retired_preset_rows, note_retired_panel_state, migrate_legacy_regex, resolve_export_dir (renamed from their _ names, no alias: the deleted panels were their only callers), plus the export_cancelled property. __init__ takes keyword-only logger=, confirm= and decide_check_failure= (the panel's collaborators), and log_level may be None (leave the adopted logger's level).
    • Test(s): test_scene_exporter.py (the renames, the collaborators, export_cancelled).
  • 2026-10-27 -- EmissiveGroupsModel and EmissiveGroupsAdapter: Emissive Groups' host-free model (new core_utils/engines/textures/emissive_groups.py, __init__.py). The registry flow, validation rules, bake plan, crossing payload and manifest both DCC libraries' EmissiveGroups share, over the region-mask engine; each host implements the adapter Protocol (membership as {object: [face indices]}, keyable weights, color writes, the mask path) and names its own nouns (COLOR_SET_NOUN, WEIGHT_NOUN).
    • Test(s): new test_emissive_groups.py (19, an in-memory host).
  • 2026-10-27 -- The Shot Manifest engine sheds its retired surface before it moves to tentacle (core_utils/engines/shots/manifest/manifest_model.py, mapping/_mapping.py). Removed: ManifestModel.asset_column and column_clipboard (deprecated since 2026-10-10 for ManifestExport), BuilderStep.asset_cell (only asset_column read it), and Mapping.retired with the speedrun translation in load_mapping (load_mapping("speedrun") now raises FileNotFoundError; the panel's picker falls back to default for a saved .active naming it, as for any missing template). The maintainer's call: compatibility shims do not travel with code that moves.
    • Test(s): test_shots_manifest_core.py drops the four tests of the removed surface.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.