- 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_tasksno longer carries it to the exporters to fold with a notice: a non-emptyversionis 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_formatis gone (MOVED_NAMES->ExportProfile.VERSION_TOKEN), andExportProfile.fold_legacy_namingtakes the RegEx field alone, keyword-only (name_regex=): itsversion_formatandtimestampparameters 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'sTextureCombine.run_transfer(geo_utils/uv/transfer_run.py, the root registry's comment). The libraries moved the entry offTextureTransfer. - 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.pytest_euler_near_takes_the_spelling_nearest_the_last_key(moved fromtest_gltf2fbx.py) andtest_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 modulesaffected_tests.pyselects, 1714 tests, 1708 passed, 6 skipped.
- Test(s):
- 2026-11-01 -- Fix:
gltf_to_fbxkeys 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'sQuaternionput 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.pynewtest_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.
- Test(s):
- 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.basenameon POSIX handsC:\tex\wood.pngback whole, soFileDependencies.resolve/candidates/find_files/claims/superseded/keep_read_elsewhereandFileCollect.entriessearched for a name no file carries. Every name a record or a host stores is read throughfile_name(either separator); the copiesFileCollectandFbxMediaspelled by hand use it too.- Test(s):
test_file_dependencies.pynewtest_a_name_stored_on_windows_is_its_file_name_on_posix,test_file_collect.pynewtest_a_path_stored_on_windows_is_searched_by_its_file_name_on_posix(posixpath.basenamepatched in; red first).
- Test(s):
- 2026-11-01 --
SceneListing.format_namesays when it does not know a case style (file_utils/scene_listing.py). A styleStrUtils.CASESdoes not name left the name as it was with nothing said, and astrmethod of its name ran ("split"raised on the suffix). It is left, with a warning naming the styles.- Test(s):
test_scene_listing.pynewtest_an_unknown_case_style_leaves_the_name_and_says_so(red first).
- Test(s):
- 2026-11-01 --
FileCollect.previewnames 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.pytest_run_previews_writing_nothing_then_commits_the_plan_shown(red first).
- Test(s):
- 2026-11-01 -- The host seams' one convention:
EmissiveGroupsHost/PureEmissiveGroupsHost/EmissiveGroupsModel.HOST; every host Protocol isruntime_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).EmissiveGroupsAdapterandEmissiveGroupsModel.adaptermoved (MOVED_NAMES, 0.19.0).PureEmissiveGroupsHostis the model with no scene: every read finds nothing, every write refuses with aRuntimeErrornaming the binding, and each host's adapter is built on it.SidecarHost,CollectHost,WorkspaceMapHost,ProbeHost,ProbeMeshandSequenceHostareruntime_checkable, so a host's suite checks its adapters withisinstance.ProbeHostkeeps 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*HostProtocol runtime-checkable, each with a Pure default that satisfies it, or a reasoned row);test_emissive_groups.pynewTestPureHost(2).
- Test(s): new
- 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) isIterUtils.values_at;FileDependencies._spelled,FbxFile._display_nameand the two_retired_row_*rules are public under those names;glb/rotationssamples a take's curves throughtake_samplersinstead of_take_key_curves/_evaluate_curve;GlbClips._lerpisMathUtils.lerp;export_snapshotbuildsscene_records.NodeNames.PresetLibraryreads versions throughPackageManager.metadata_versionandPackageRequirement.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_mediadrops a repeated import.- Test(s):
test_iter.pynewtest_values_at_walks_keys_and_fans_out_over_lists;test_export_snapshot.pyshots fixtures in the record's own shape (clip, integer frames).
- Test(s):
- 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).TransferRunis 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 inREFUSALS), andrun(**rows)-- the UV pass or the texture pass with Overwrite target's report and the lightmap pass, in one undo step -- over aTransferHostadapter (mayatk's / blendertk'sTextureTransfer.runhands it theirs;PureTransferHostwith no DCC: nothing selected). It returns aTransferResultof 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.maplayblasts asv1.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 underexport_name(the scene's name,untitledwhile 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_reporttakesmax_items_per_category=Nonefor 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.pynewOutputDefaultTestCase(4),test_workspace.py+1,test_scene_listing.pynewTestListingRows(4),test_hierarchy_utils.py+1 -- each red first (errors on the missing API; the dotted name failed in tentacle'stest_rendering_mixin). The 17 affected modules (withtest_surface_snapshot,test_host_seams,test_module_resolver): 1082 tests, 1081 passed, 1 skipped; after the dotted-name fixtest_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 anordercallable, it strips what a previous run left (a trailing number, also afternumber_separatorssuch as Blender'stile.001; the batch's own run of letters fromA; withstrip_defined_suffixesa known type suffix, stacked ones one per pass), groups withindependent_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'sNaming.append_location_based_suffixeach carried a copy of this block and now call it.- Test(s):
test_str.pyfive newtest_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 modulesaffected_tests.pyselects, 660 passed, 0 failed.
- Test(s):
- 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 raisesShotBoundaryConflict. Withon_refusalthe 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 toon_refusal-- after the step closed, so a panel that redraws there sees the scene as the edit left it. A refused edit says noShotsEdited; 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.pynewTestARefusedEdit(7), red first (6 errors);test_shot_sequencer.pynewtest_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).
- Test(s):
- 2026-10-31 - Fix:
FbxMediareads 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 inrecord,records,dependenciesandaudio_events. The materials reader (_carried_record) and unitytk'sCarrierFiletake carriers alone. A Model thatSceneStoreBase.is_carrierdoes not name is now skipped, so all the readers agree. Found by a review of unitytk'sCarrierFile.- Test(s):
test_fbx_media.pynewtest_a_record_of_its_name_on_another_node_is_no_record(red first). Green: the 7 modulesaffected_tests.pyselects, 1073 passed, 1 skipped. unitytk's reader fixtures read in Unity against this reading (12 passed).
- Test(s):
- 2026-10-31 - Fix: the dependency pass embeds every map the
materialsrecord names (file_utils/mesh_convert/fbx_media.pyFbxMedia.DEPENDENCY_FIELDSgains amaterialsrow;export_verify.pydocstring). The table had nomaterialsrow. 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 namedop_basecolor.png, which the FBX lacked). The row walks everyMaterialFamilies.TEXTURE_FIELDSpath, 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'sfbx_dependenciesgate (a named map missing from the file now fails it),extract_dependenciesanddrop_host_lighting_media. The GLB applier already reads a map off the file (FbxMedia.extract).- Test(s):
test_fbx_media.pynewtest_the_materials_records_maps_ride_in_like_the_other_records(one embedded, one present, one missing, a shared name once) andtest_mesh_convert.pynewtest_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 modulesaffected_tests.pyselects, 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:TestMaterialsRecordThroughTheConverter4,EmbedTestCase31). mayatk's 17 affected modules: 2155 tests, 0 failures, 2 skipped. blendertk's 16: 2525 checks passed. unitytktest_maya_surface_materials_integration11 passed in fresh batch Unity 6000.3.10f1.
- Test(s):
- 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_Nvariants, not a Collect's folders, so it cannot runapply, and its own removal stranded every reader outside the batch (mayatk, blendertk).finish_moveis 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, asapplykeeps). A tile set goes by its tiles, one source twice goes once; it returns(taken, kept).- Test(s):
test_file_collect.pyTestFinishMove(4). The 5 affected modules: 188 tests, 187 passed, 1 skipped.
- Test(s):
- 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) andslerp, on(x, y, z, w)tuples:a (x) bappliesbfirst, 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._decomposekeeps 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,arcreading asslerpbit for bit);test_articulation.py,test_articulation_plan.pyre-pointed. The 125 affected suites: 7042 run, 7010 passed, 32 skipped.
- Test(s):
- 2026-10-31 --
FileCollect.set_directoryand 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 aCollectHost(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 (orallow_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, isBLOCKED-- never settled by order.applyjudged 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).outcomenames 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.pyTestFlatPlan(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 onflat=). The 10 affected modules: 839 tests, 838 passed, 1 skipped.
- Test(s):
- 2026-10-31 -- Fix: a node scaled flat on one axis keeps its rotation through FBX2glTF (
file_utils/mesh_convert/glb/rotations.pynewGlbRotations; newMeshConvert.restore_glb_rotations(glb, fbx, fps=None), run infbx_to_glb's session;MeshConvert.CONVERSION_BAKE_FPS24 -> 30;fbx_file.pyFbxFile.take_keysover 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 ownLcl Rotationcurves 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 whereRotationActiveis 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-frameratehonoured) 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.pyTestNonFiniteKeys.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), newTestFlatNodeRotations(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_bakeat 30 fps;test_fbx_media.pybuild_curve_fbxtakesposes,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.
- Test(s):
- 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.pynewCollectHost.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), andplandecides 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 whatapplydoes 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, andrunreports it.- Test(s):
test_file_collect.pytest_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).
- Test(s):
- 2026-10-31 -- Fix: the
audiorecord's digests describe only bytes the file embeds, and the FBX dependency pass writes them (file_utils/mesh_convert/fbx_media.pyFbxMedia.embed_dependencies, new_digest_audio;audio_utils/_audio_utils.pyAudioUtils.media_digests(events, files)now over{file name: bytes};audio_utils/audio_record.py;core_utils/engines/scene_export/scene_records.pySceneRecords.AUDIOassembly;DELIVERABLE_FORMAT.mdregenerated). 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 setsmediafrom 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_digeststakes 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.pyTestMediaDigestsrewritten (4; the vacuoustest_alike_bytes_digest_alike_wherever_they_lieremoved);test_audio_record_shape.py(the producers leavemediato the pass). Red first: 1 failed, 5 errors. The 33 affected suites: 2638 run, 2628 passed, 10 skipped;sync_scene_records.py --checkin sync.
- Test(s):
- 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_objectsmoved down fromPreviewBridge, 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'sUnityBridge)._preflightnow refuses a send with no deliverer for its mode and no_deliverof its own, naming what is missing, where it used to export and then raiseNotImplementedError: a bridge whose caller attaches the delivery (deliverer=) says so before the export is paid for.- Test(s):
test_bridge.pyHandoffScopeObjectsTest(3),HandoffWithoutDeliveryTest(3), red first.
- Test(s):
- 2026-10-31 -- Removed the retired GLB material writers
MeshConvert.set_glb_base_color,set_glb_emissive,set_glb_metallic_roughness,set_glb_alpha_modeandset_glb_unlit(MOVED_NAMESrows namingMeshConvert.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_materialsstates each material as the FBX'smaterialsrecord does, through the sameGlbEditsession.set_glb_normal_scale(the preview's normals dial) stays.- Test(s):
test_mesh_convert.pyTestGlbEditSessionre-pointed atapply_glb_materialsrecords (one write per session, withset_glb_normal_scaleas 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 fromtest_preview_server.pySetGlbEmissiveTestCase(removed) and its non-GLB case inTestGlbReadGuards;TestMaterialsRecordtest_a_mask_states_its_cutoff,test_any_other_mode_drops_a_stale_cutoff; the writers' own tests removed (TestBaseColorRecordHonesty,TestSetGlbAlphaMode,TestSetGlbUnlit,TestSetGlbBaseColorAlphaand the ORM writer's three).
- Test(s):
- 2026-10-31 -- The
audiorecord 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.pynew optionalAudioRecord.media;audio_utils/_audio_utils.pynewAudioUtils.media_digests;core_utils/engines/scene_export/scene_records.pySceneRecords.AUDIOassembly;DELIVERABLE_FORMAT.mdregenerated). An event names its track and Unity finds the clip by that name, so two models each embedding a differentdoor.wavbound 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).mediais{track name: SHA-256 of the file's bytes, lowercase hex}for each track file the deliverable embeds, computed once here from the host'strack_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.pynewTestMediaDigests(4);test_audio_record_shape.pynewtest_each_played_track_file_rides_by_its_name_as_its_digest,test_a_record_written_before_the_digests_still_reads, twomediarefusals and the schema's optionalmedia, 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 --checkin sync.
- Test(s):
- 2026-10-31 -- Added
FileCollect,CollectHostandPureCollectHost(file_utils/file_collect.py), andFileDependencies.candidates/choose/on_disk/same_content/keep_read_elsewhere;FileDependencies.resolve(..., key=)and its records'candidates;FileDependencies.relocateis 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 byFileCollect.row_key((kind, owner)).FileCollect.runreports a plan or commits it over a host (stored_path,resolve_path,stored_form,repath).relocate's result gainsambiguous,blockedandplan, a record'skeep_sourcecopies it in a move, and its copier is handed"copy"and aprogress=callback (copy_files(..., progress=));find_filesmatches a tile set by its tiles.- Test(s):
test_file_collect.py(new, 29);test_file_dependencies.pyTestChoose(new),TestResolvetest_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,TestRelocatetest_plan_then_copy_an_ambiguous_name_is_never_guessed,test_a_same_named_file_already_there_keeps_the_newcomer_apart(red first).
- Test(s):
- 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_UNITSgains("blender", "usd"): one BU a metre, measured 2026-10-31 under everyconvert_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.pytest_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).
- Test(s):
- 2026-10-31 -- Added
FbxMedia.state_materials(src, dst=None),FbxMedia.legacy_material_values(entry)andFbxMedia.LEGACY_MATERIAL_ROWS, run byMeshConvert.finish_fbx_deliverable(file_utils/mesh_convert/fbx_media.py,_mesh_convert.py). Eachmaterialsrecord entry is written onto its FBX material's legacy properties (DiffuseColorxDiffuseFactor,EmissiveColorxEmissiveFactor, 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.pyStateMaterialsTestCase(new).
- Test(s):
- 2026-10-31 -- Removed the scene sidecar's material sections:
MeshConvert.apply_scene_sidecar,embed_scene_sidecar,sidecar_foreign_packingsandSIDECAR_APPLIERS(MOVED_NAMESrows), and the section branch offbx_to_glb(file_utils/mesh_convert/); the opacity rule and the transmission note are the model's (MaterialFamilies.opacity_scalar,TRANSMISSION_NOTE, thepbrfamily'stransmission). The FBX'smaterialsrecord 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.pytest_an_opacity_colour_reads_as_its_channels_mean,test_a_transmission_is_said_not_stated;test_mesh_convert.pyTestSceneSidecar(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).
- Test(s):
- 2026-10-31 -- Added
PackageRequirement: a pip requirement read and compared withoutpackaging; addedPackageManager.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.9admits no0.9.0rc1,>1.7no1.7.post1).version_keyandreleaseexpose 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 raisesValueError.metadata_versionreads the running interpreter's installed version with nopip showsubprocess. uitk's version-aware install-on-demand reads ranges through it, aspackagingis not guaranteed inside a DCC interpreter. Fix:PackageManager.pipsplit a command string on commas too, soinstall("extapps>=0.8.0,<0.9")reached pip as two arguments (<0.9is no requirement); it splits on whitespace only.- Test(s):
test_package_requirement.py(20, red first),test_package_manager.pyMetadataVersionTests(3) +test_parse_command_keeps_a_specifier_set_whole,test_install_and_update_hand_pip_a_range_as_one_requirement(red first).
- Test(s):
- 2026-10-31 -- The writer states its unit:
ExportContext.unit_scale,ExportContext.WRITER_UNITS,ExportContext.writer_unit(host, writer, scene_unit, options)andExportContext.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_lengthwithapply_unit_scaleon, timesglobal_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.pyTestWriterUnits.
- Test(s):
- 2026-10-31 -- Every export writes data for the objects it ships:
ExportContext.nodes,ExportContext.ships/narrowed,RecordSpec.nodesandRecordSpec.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_groupsandrender_effectsare export-scoped;emissive_groupsis 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_ENTRIESmoved toRecordSpec.nodes(aMOVED_NAMESrow);FbxMedia.reader_recordsnarrows a module's copy through it.- Test(s):
test_export_snapshot.py(new),test_emissive_groups.pytest_a_scoped_write_names_the_groups_with_a_shipped_member.
- Test(s):
- 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.pytest_to_identifier(red first).
- Test(s):
- 2026-10-31 --
StrUtils.set_casetakesupper_firstandupper_initials(str_utils/_str_utils.py,CASES). Python'scapitalize/titlelower every other letter, so a name lost its camelCase and side markers (myBigBox->Mybigbox,arm_L_ctrl->Arm_l_ctrl).upper_firstraises the first letter andupper_initialseach word's first letter, keeping the rest (MyBigBox,Arm_L_Ctrl).capitalizeandtitlekeep Python's meaning, because saved settings name them (the Shot Manifest'sobject_case, the Reference Manager's case styles).- Test(s):
test_str.pytest_upper_first_and_upper_initials_keep_the_rest(red first).
- Test(s):
- 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 ofcrate_GEOiscrate_GEO1, which came outcrate_GEO1_GEO;wall_GEO_01came outwall_GEO_01_GEO). Withnumbereda trailing_?\d+or Blender.001is 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_02still stays whole.- Test(s):
test_affix_hardening.pyStripAnyAffixTest.test_numbered_strips_an_affix_a_number_trails(red first).
- Test(s):
- 2026-10-31 --
RenamePlan.apply(park=...)runs a batch in two phases; addedRenamePlan.predict(plan, scopes, names, uniquify)(file_utils/file_naming.py). Withpark, 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 measuredobj1 -> obj2,obj2 -> obj1asobj3, 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 asp0000000000).predictreturns the names a uniquifying host assigns to such a run, so a dry run previewsbox, box1, box2instead ofbox, box, box; the host's name pools and its rule for a taken name are callables.- Test(s):
test_file_naming.pyTestRenamePlan(+7: the swap, a locked item, a failed rename unparked, no park on a dry run, three predict cases; red first).
- Test(s):
- 2026-10-31 -- Added
PointCloud.order_points(points, order_by="auto", reference, tolerance=1e-3, reverse),PointCloud.spread_axisandPointCloud.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.pyTestOrderPoints(+10, red first).
- Test(s):
- 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 (aByPolygonVertex/Directlayer), 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.pyRepairNullNormalsTestCase(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).
- Test(s):
- 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;strictstops 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.pyTestImportFiles(red first for the logging and the wording).
- Test(s):
- 2026-10-31 -- Added
SceneListing(file_utils/scene_listing.py): the Reference Manager's listing and Naming rules over a project'sWorkspace.save_target,default_save_name,normalize_structure_patternand the listing rules, which tentacle's panel held.- Test(s):
test_scene_listing.py(new).
- Test(s):
- 2026-10-31 --
PresetStore(info=False)andPresetStore.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.presetsidecar;delete(trash=True)sends a user preset to the platform trash. A session's saved mixes are a sidecar-freePresetStore, one<name>.jsoneach inLightMix.save's format.- Test(s):
test_preset_store.py,test_look_session.py(the mixes folder holds the mixes alone, red first).
- Test(s):
- 2026-10-31 --
ShotStore.save_user_prefs(was_save_user_prefs) andShotSequencer.source_shot_id(was_source_shot_id_for) are public; addedShotSequencer.keyed_transforms(start, end, **kwargs)(engines/shots/). tentacle's panels read them;keyed_transformsreads the private host hook_find_keyed_transformseach host overrides.- Test(s):
test_shot_sequencer.py,test_shots_core.py.
- Test(s):
- 2026-10-29 --
PreviewServeris a facade over private collaborators, not four single-use mixins (net_utils/preview/). Every public name, signature and docstring is unchanged._PreviewSharealone owns the share's security state (guest listener, admitted names, guests, tunnel, private segment, share lock and generation);_ServeRoot,_PageOutputsand_SceneDescriptionown 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.jsonwith 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).
- Test(s):
- 2026-10-29 -- Generic names leave the root;
reload_packageis retired (__init__.py).Kind/MergeareSceneRecords.Kind/.Merge,OpisMapOptimizer.Op,reload_packageisModuleReloader().reload(MOVED_NAMES).Call/ResultareRpcJob.Call/.Resultbehind a deprecated alias (remove_in 0.20.0), because the released extapps imports them.- Test(s):
test_module_resolver.py,test_surface_snapshot.py.
- Test(s):
- 2026-10-29 -- The Scene Exporter contract knows no Qt (
engines/scene_export/).ExportProfile.read_values,.value_methodand.CHECKBOXmove to tentacle'sSceneExporterSlots(MOVED_NAMES). The Output Format row is declared inSceneExporterBase.SETTINGS_DEFINITIONS; its objectName is the savedcmb004.- Test(s):
test_export_profile.py,test_scene_exporter.py.
- Test(s):
- 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_constantraises on an unreadable image;get_image_infonames and skips one.Metadataraises on a failed sidecar write and on Windows without pywin32.UserConfig.ROOT_NAMEandUserConfig.CONFIG_ROOT_ENV_VARare class data; the saved location is unchanged.- Test(s):
test_map_optimizer.pyTestReportShownByDefault,test_img.py,test_map_factory.py,test_metadata.py(red first).
- Test(s):
- 2026-10-31 -- Fix:
fbx_to_glbno 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 fixturemaya_tangents.fbx). The newMeshConvert.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_fbxnow 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::TestNonFiniteKeys10 + 1 expected failure (the converter's own flat-node drop, ledgered),test_gltf2fbx.py4 refusals; all red first.
- Test(s):
- 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(withsplits),FileDependencies.strangers,SceneRecords.resolve_row,SceneRecords.resolved_producers,SceneRecords.resolved_stagers(each takes the host'slog=),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.
- Test(s):
- 2026-10-29 --
SequenceExporterreaches its host throughSequenceHost(vid_utils/sequence_exporter.py). The image-sequence plan and its ffmpeg encode are written once overSequenceEncoder.HOST(aSequenceHost: frame range, sequence name and fps, the armed audio, path resolution, frame/still/movie capture, a host-only target kind, notices) withPureSequenceHost's defaults; mayatk's and blendertk'sPlayblastExportereach bind their adapter instead of overriding the base.frame_paddingis 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).
- Test(s):
- 2026-10-29 -- The key stash is one store over a host adapter (
core_utils/engines/key_stash).ptk.KeyStashgainsHOST(aKeyStashHost: persistence, scene clock, flush, undo chunk, in-step write, reconcile, script jobs, per-store state,keys_keep_time) overPureKeyStashHost, and the bodies both DCCs wrote twice:watch_undo,merge_carrier,reconcile, the host backendactive()installs, and the frame-rate rule (rescale where keys keep time, record the rate otherwise).ATTR_NAMEandUNDO_EVENTSmove onto it.KeyStashConformancepins the savedkey_stashrecord 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).
- Test(s):
- 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 --
MeshConvertis 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_fbxruns 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 andmock.patch.object(MeshConvert, ...)reach every internal call, anFBX_FAMILIESdialect row whose method a subclass adds included. No public change; the private bases leave the class (MeshConvert.__bases__ == (HelpMixin,)).- Test(s):
test_mesh_convert.pyTestFacadeComposition(the subclass dialect row, red first; every delegation's signature and forwarding pinned to its part's).
- Test(s):
- 2026-10-28 -- One shot store on every host:
ShotStoreruns on an injectedShotsHost(core_utils/engines/shots/shot_model.py,shot_conformance.py). New root exportsShotsHost,PureShotsHostandShotTransferHost(the hand-off codecShotsHost.transfer()returns). A store takeshost=;ShotStore.set_host,register_host(name, resolve)andrunning_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'sundo_stepand native-step crossing;detect_regions,publish_export_viewand the persistenceactive()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.ShotStoreConformanceis 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.
- Test(s):
- 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.HOSTwith the newSidecarHostProtocol andPureSidecarHost(expand_to_descendants,drop_intermediate,build_clean_path_setare classmethods through it);HierarchyBaselineStore._closedelegates to itsSIDECAR._close;SceneExporterBase.STORE(the saved scene path isSTORE.scene_path());ProbePlacement(baked, unbaked, host=)or one adapter per placement fromHOST_CLASS, with the newProbeHostandProbeMesh, andProbeRays(mesh). TheProbePlacement.UP/INTERNAL_Mclass attributes are gone (the instance'supandINTERNAL_Mcome 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).
- Test(s):
- 2026-10-28 --
CurtainDrapeis written once, in pythontk (geo_utils/curtain_drape.py). The pleated, catenary-sagged drape overRailSurfacewas a vendored twin in mayatk and blendertk; both hosts'CurtainMeshnow inheritptk.CurtainDrapeand its constructor. Same contract and output (the grid compared point for point).- Test(s):
test_curtain_drape.py(moved from mayatk).
- Test(s):
- 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.
- Test(s):
- 2026-10-28 --
ShotStore.EditHandle(core_utils/engines/shots/shot_model.py). Whatscene_edityields:cancel()declares the body a no-op, so the edit leaves no restore point and says noShotsEdited-- one idiom for a panel both hosts share.- Test(s):
test_shots_core.py+1.
- Test(s):
- 2026-10-28 --
ShotReportwords the shot-removal question both shots panels ask (core_utils/engines/shots/shot_report.py). NewSCOPE_LABELS((label, scope)rows inShotStore.DELETE_SCOPESorder),SCOPE_CLAUSES,LISTED_MAX,scope_label(scope),scope_count(shots, scope)andremoval_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).
- Test(s):
- 2026-10-28 --
SceneExporterBaseis 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 theexport_cancelledproperty.__init__takes keyword-onlylogger=,confirm=anddecide_check_failure=(the panel's collaborators), andlog_levelmay beNone(leave the adopted logger's level).- Test(s):
test_scene_exporter.py(the renames, the collaborators,export_cancelled).
- Test(s):
- 2026-10-27 --
EmissiveGroupsModelandEmissiveGroupsAdapter: Emissive Groups' host-free model (newcore_utils/engines/textures/emissive_groups.py,__init__.py). The registry flow, validation rules, bake plan, crossing payload and manifest both DCC libraries'EmissiveGroupsshare, 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).
- Test(s): new
- 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_columnandcolumn_clipboard(deprecated since 2026-10-10 forManifestExport),BuilderStep.asset_cell(onlyasset_columnread it), andMapping.retiredwith thespeedruntranslation inload_mapping(load_mapping("speedrun")now raisesFileNotFoundError; the panel's picker falls back todefaultfor a saved.activenaming 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.pydrops the four tests of the removed surface.
- Test(s):