- 2026-11-11 -- Fixed: a call failing while the next call restarts its session instance says why, instead of "cannot join thread before it is started" (
core_utils/operation_catalog/session_operation_host.py_launch_bound,_log_tail). The new instance's log drain was published as_drainbefore it started, and the failing call, reading the log tail from its own thread, joined it in that window (Windows CI, intermittent). The drain is now started, then published, and the tail reads it once.- Test(s): red first, then green:
test_session_operation_host.pynewtest_the_log_drain_is_published_only_once_it_runs(pins the order without the race:[True]before,[False]after); the module 38 passed; the racytest_a_call_waiting_when_its_instance_dies_runs_in_the_next_and_says_so5 runs green.
- Test(s): red first, then green:
- 2026-11-11 -- Fixed: a texture PyMeshLab cannot read is said, not silently dropped (
file_utils/mesh_ops.pyMeshOpsnew_load_mesh, used bysessionandlods;_save_mesh). PyMeshLab swaps a texture it cannot read -- a missing file, or a JPEG on Linux, whose wheel ships no Qt image plug-ins -- for a blankdummy.pngand says nothing, so a mesh op's output carried no texture with no word of it, and a save wrote that blank image beside the output. The load now warns (put the image beside it, or convert it to PNG), and the stand-in is never written.TestTextured's fixture texture is a PNG, so every platform runs the same checks (they failed on Linux CI: the JPEG never loaded).- Test(s): red first, then green:
test_mesh_ops.pynewtest_a_texture_it_cannot_read_is_said; the module 49 passed on Windows and on Linux (WSL Ubuntu, the Linux wheel), wheretest_a_save_elsewhere_takes_the_texture_alongandtest_another_image_of_the_same_name_is_kept_and_saidfailed before; affected tests 226 passed, extapps 67.
- Test(s): red first, then green:
- 2026-11-10 -- LOD pipeline end to end, and its full review (
file_utils/lod_chain.py,file_utils/mesh_convert/{export_verify.py, fbx_media.py, _fbx2gltf.py, _gltf2fbx.py, _mesh_convert.py, glb/lods.py, glb/edit.py, glb/reader.py},mesh_ops.py,core_utils/engines/scene_export/{scene_exporter.py, scene_records.py},net_utils/preview/kernel/{lods.js, surfaces.js}).- New verification gates (
ExportVerifier, run with the others):check_fbx_lods: each group an FBX names states a LodGroup with one threshold per switch, writes no level hidden, and agrees with thelodsrecord (groups, levels, heights).check_glb_lod_deviation: eachMSFT_lodlevel measured against its LOD0 in world space (MeshOps.compare), warned past 10% of LOD0's size; bounded (24 groups, 250k LOD0 triangles), "not measured" without[mesh].
LodChain:- A level whose world bounds stray from LOD0's is a note (it pops at its switch); nodes may carry
bounds. - New
record_groups, thelodsrecord's entries both hosts publish. groups,planandrefine_selectionare linear: 3000 groups took 8 s to refine, 6000 under one parent 12 s to plan.
- A level whose world bounds stray from LOD0's is a note (it pops at its switch); nodes may carry
- FBX LodGroups:
- New
FbxMedia.set_lod_thresholds, which states the heights on a LodGroup written without them. - New
FbxMedia.lod_heights, its reader;GlbLods.fbx_heightsmoved there, unreleased. - New
FbxMedia.lod_group_rows, the one row table both writers emit. The GLB-to-FBX writer wrote oneDisplayLevelsrow per level; it now writes one per threshold, as a DCC's writer does. - Fixed: an empty
Properties70a writer left without its NULL sentinel gains one when it gains rows. A strict reader (a DCC's FBX importer) refused the rewritten file.
- New
- GLB:
GlbEdit.graft_meshesdeclares a copied material's extensions inextensionsUsed. Without them the Khronos validator failed the file.GlbLods.mergegrafts only the meshes a chain keeps, so no dead geometry is left.- A malformed
MSFT_lod.idsnaming an ancestor no longer loopsGlbReader.world_matrix. GlbLods.applywalks paths iteratively; 3000 nodes deep raised.- Rejected heights and an unreadable FBX threshold now warn.
- Viewer:
- A LOD group switches by its distance from the object, not from the group's origin, which a DCC's group leaves at identity. A mesh 20 m from its group showed the cull up close.
- Walkable surfaces read each group's LOD0 alone.
MeshOps:- An image of the same name beside the output is the mesh's own only when it is the source's; another is kept and said.
lodsfrees LOD0 once written.- The session's
last_decimationstates what the collapse applied.
SceneRecords.named_nodes(skip=)reads a generator once.- New
SceneExporterBase._state_lod_variants, the USD variant report both hosts gave. - Application names left the new prose.
- Test(s):
test_lod_chain.py:LevelBoundsTest,RecordGroupsTest,CostTest.test_glb_lods.py:CheckFbxLodsTest(7),LodDeviationTest(3), the threshold writer (red on the sentinel), a distance-threshold group, the ancestor-ids cycle, a 3000-deep tree, dead geometry, the material extension.test_mesh_ops.py: the foreign image,last_decimation.test_scene_records.py: a generatorskip.test_preview_viewer_live.py:test_a_lod_group_switches_by_its_distance_from_the_object(red: level 2 at 1.5 m).- Most written red first. The surfaces walk has no test: the page exposes no surface list.
- New verification gates (
- 2026-11-10 -- LOD review fixes (
file_utils/lod_chain.py,file_utils/mesh_convert/glb/lods.py,glb/edit.py,mesh_ops.py,core_utils/engines/scene_export/export_profile.py,net_utils/preview/kernel/lods.js,__init__.py). From the P6 review:- Level numbers are bounded.
LodChain.groupschecks a level number against the 8-level limit before sizing anything by it:x_LOD20000000took 116 s and 2.5 GB, and a building model'sWall_LOD200raised 300 warnings. Each is now one issue. - New
LodChain.kept_heights. When a write ships only some of a group's levels, each kept level keeps its switch height and the last one keeps the group's cull. Both hosts' records andGlbLods.renumberuse it; removing the last level used to raise the cull. GlbLods.expandleaves a group past 8 levels as written. The GLB-to-FBX conversion used to fail on one.GlbLods.mergeworks on a copy and replaces the destination only when the merge is whole, so a refusal writes nothing. A skinned node keeps its one mesh.MeshOps.lodsvalidates its ladder before it writes anything.- Textured decimation names the options its UV-keeping collapse cannot apply.
ExportRun.LOD_MODESis nowLodChain.MODES.- New root exports:
LodsRecordandLodGroupEntry. - Task order:
prepare_lodsruns after the name repair, so its plan never holds a path the repair renamed. - The viewer retunes each LOD's switch distances to the camera that renders the frame. A start camera or a headset sets its own lens, and the fov was read once at load.
- Wording: the docs name no application (check_layers rule 6).
- Test(s):
test_lod_chain.py:BoundsTest,test_kept_levels_keep_their_own_switch_and_the_groups_cull.test_glb_lods.py: the last-level cull, the level limit inexpand, merge refusals that write nothing (including a textured level), a skinned node.test_mesh_ops.py:test_a_bad_ladder_is_refused_before_anything_is_written.test_export_profile.py: the mode tie and the new task order.test_export_snapshot.py:lodsamong the export-scoped records, missed in P4.test_preview_viewer_live.py: the switches follow the rendering lens.- All written red first.
- Level numbers are bounded.
- 2026-11-10 -- Fixed: decimating a textured mesh keeps its UVs, and a save beside the source leaves its texture alone (
file_utils/mesh_ops.pynew_MeshSession.decimateand thedecimate_quadric_textureop;MeshOps.decimate,MeshOps.cleanand the session'ssave). PyMeshLab's plain quadric collapse ignores UV seams: on a seamed atlas it left 6% of the UV area (the texture smeared across every face). A mesh with UVs and a texture now collapses with the texture-aware filter (94% of the UV area, no flipped face, measured), and a mesh with UVs but no texture warns that they will not survive and how to keep them (that filter needs every face to name a texture). A MeshLab save writes the mesh's textures beside the output, so a stage writing beside its source re-encoded the source's own JPEG each time; the save now leaves an image already there as it is (written elsewhere, the textures still go along).MeshOps.decimatewithcurvature_weightedorpreserve_topologyon a textured mesh drops those two: the texture-aware filter has neither.- Test(s):
test_mesh_ops.pyTestTextured(red first: the UV area at 6-59%, the source JPEG changed, no warning), 5 passed.
- Test(s):
- 2026-11-10 --
MeshOps.lods: a LOD chain of any mesh file, headless (file_utils/mesh_ops.py).<stem>_LOD0(the mesh as it is) and each lower level<stem>_LOD<n>beside it, each a quadric decimation of LOD0 measured against it (compare's peak and mean). Coarseness is a ladder (levels,ratio:LodChain.ladder) or rising deviationbudgets, met by bisecting each level's triangles on the measured peak: no simplifier's own error bound held in the LOD bake-off. A textured mesh keeps its UVs.- Test(s):
test_mesh_ops.pyTestLods(red first: no attribute),TestTextured.test_a_textured_lod_chain_keeps_its_uvs.
- Test(s):
- 2026-11-10 -- GLB to FBX carries LOD groups (
file_utils/mesh_convert/glb/lods.pynewGlbLods.expand;_gltf2fbx.pywrites a LodGroup model and attribute,MSFT_lodamong the understood extensions). The way back fromGlbLods.apply: eachMSFT_lodgroup becomes<base>over<base>_LOD0..n(another writer's group gets a<base>node in LOD0's place and the level names), and the FBX carries it as Maya writes it: a LodGroup model whose attribute states the switch heights in percent. A second instance's numbered tail goes on the group, its levels named after it (bolt_001_LOD0). Measured: Maya 2025 imports it as a lodGroup at the GLB's heights (60/30%), the same as its own export.- Test(s):
test_glb_lods.pyExpandTest(red first),test_gltf2fbx.pytest_a_glb_lod_group_lands_as_an_fbx_lod_group,test_a_glb_lod_group_without_heights_takes_the_default_ladder.
- Test(s):
- 2026-11-10 -- One LOD GLB from GLBs of one tree at falling detail, a CAD import's profiles among them (
glb/lods.pynewGlbLods.merge;glb/edit.pynewGlbEdit.graft_meshes;_mesh_convert.pynewMeshConvert.merge_glb_lods). Each mesh node of the finest GLB becomes a LOD group, its levels the matching nodes of the others that draw fewer triangles (a mesher's floor leaves a level out for that node), stated inMSFT_lod; instances keep sharing each level's mesh, and trees that differ are refused.graft_meshescopies meshes between GLBs with their accessors, bufferViews (each once, stride and all) and materials (an equal one shared). A CadImport record is a source of its own (MeshConvert.merge_glb_lods(CadImport.convert(step, profiles=("hero", "realtime")))): its levels are tessellations of the exact surfaces, not decimations. The Khronos validator passes the merged GLB with 0 errors and 0 warnings.- Test(s):
test_glb_lods.pyMergeTest(red first: no attribute), 5;test_cad_import.pytest_the_profiles_merge_into_one_valid_lod_glb.
- Test(s):
- 2026-11-10 -- New record
lods(core_utils/engines/scene_export/scene_records.pySceneRecords.LODS;file_utils/lod_record.pynewLodsRecord/LodGroupEntry; the generated schema,DELIVERABLE_FORMAT.mdand unitytk'sLodsRecord.g.cs). Per LOD group its node, its levels (LOD0 first) and each level's switch height, for unitytk: Unity's FBX import merges every level-named mesh of a file into one LODGroup on its root and drops the FBX's thresholds (measured, Unity 6000.3). A GLB states LODs inMSFT_lodand USD in a variant set, so neither carries it.SceneRecords.named_nodesgainedskip=: Generate Missing does not read thelodsrecord an earlier export stored as a node it must leave alone.- Test(s):
test_scene_records.pyTestNamedNodes(skip).
- Test(s):
- 2026-11-10 --
LodChain.variant_plan: the USD variant set each LOD group becomes, as plain data a host's USD writer authors (file_utils/lod_chain.py, newUSD_VARIANT_SET). A variant setLODwith variantsLOD0..n(Unreal'sinterpret_lods), one level each, LOD0 selected;pxrstays in the hosts.- Test(s):
test_lod_chain.pyVariantPlanTest.
- Test(s):
- 2026-11-10 -- The WebXR preview switches a GLB's LOD groups as the camera moves (
net_utils/preview/kernel/lods.jsnew;kernel/load.js,kernel/specs.js). three's GLTFLoader does not readMSFT_lodand draws LOD0 alone (the spec's fallback); the page now builds aTHREE.LODper group after the parse, its switch distances from the file'sMSFT_screencoverageheights at the camera's field of view (the cull an empty last level), before every pass that binds by node. The model's triangle count is what is drawn up close: LOD0's.- Test(s):
test_preview_viewer_live.pytest_a_lod_group_switches_levels_with_the_camera(headless Edge: three levels and the cull, levels 0/1/cull by distance, LOD0's 64 triangles, no console error); the live viewer suites 440 passed (behaviour unchanged otherwise).
- Test(s):
- 2026-11-10 -- A GLB's readers and gates know
MSFT_lod(file_utils/mesh_convert/glb/lods.pynewGlbLods.parents;glb/reader.pyGlbReader.parent_of;export_fingerprint.pynode keys and a node'slodentry;export_verify.pynewcheck_glb_lods). A group's lower levels, kept out of the node tree, read under their LOD0's parent (one parent map for the reader and the fingerprint), the fingerprint names each group's levels, and the verifier fails a group whose ids name no node, whose levels are still in the tree (every reader draws them) or whose heights do not fall level by level.- Test(s):
test_glb_lods.pyReadersTest,VerifyTest(16 in all).
- Test(s):
- 2026-11-10 -- Generate Missing and LOD Output for the Scene Exporter's LODs row (
export_profile.pyLOD_OPTIONSgained Generate Missing, newLOD_OUTPUT_OPTIONS,ExportRun.lod_write_back, thegenerate_lodstask after the name hygiene and inOBJECT_SET_TASKS;file_utils/lod_chain.pynewLodChain.ladderand the generation defaults;scene_records.pynewRecordSpec.named_nodes/SceneRecords.named_nodes).from_tasksarmsgenerate_lodsfor Generate Missing alone;laddergives each generated level's triangle target (half the last, stopping above a floor);named_nodeslists the scene names a record holds, which the generation leaves alone (a regrouped or renamed node would leave the record naming nothing).- Test(s):
test_export_profile.pytest_generate_missing_arms_the_generation_and_its_output_mode;test_lod_chain.pyLadderTest, Generate Missing inPlanTest;test_scene_records.pyTestNamedNodes.
- Test(s):
- 2026-11-10 -- A GLB states each LOD group in
MSFT_lod(file_utils/mesh_convert/glb/lods.pynewGlbLods;_mesh_convert.pynewMeshConvert.apply_glb_lods;_fbx2gltf.pythe pass in the FBX-to-GLB chain, and_remove_glb_nodesrenumbersMSFT_lodids). FBX2glTF writes a group's<base>_LOD0..nlevels as sibling nodes, which every glTF reader drew at once. The pass gives each well-formed group's LOD0 nodeextensions.MSFT_lod.ids(the lower levels, out of the node tree) andextras.MSFT_screencoverage(each level's switch height, the cull last: the source FBX's LodGroup thresholds where it states them in percent, else the default ladder), listed inextensionsUsedonly, so a reader without it draws LOD0 alone. It runs after every pass that binds by name in the node tree, and a later node removal keeps its ids valid (a removed level takes its id and its height). A malformed group is left as written. Measured end to end on a Maya FBX: its lodGroup (50/20%) and a Generate LOD Meshes group (64%) both came out stated, and the Khronos validator passed the GLB with 0 errors and 0 warnings. The pass chain is still hand-ordered, so this is one more placed step; the missing pass table is logged in FUTURE.md.- Test(s):
test_glb_lods.py(new, red: no module), 10 passed.
- Test(s):
- 2026-11-10 -- The Scene Exporter reads the scene's LOD groups on every export, and a LODs row decides what ships (
core_utils/engines/scene_export/export_profile.pynewExportProfile.LOD_OPTIONS,RETIRED_LOD_ROW,ExportRun.lods/LOD_MODES, theprepare_lodstask inTASK_ORDERandOBJECT_SET_TASKS,check_geometry_lod_suffixout ofCHECK_DEPENDENCIES;file_utils/lod_chain.pynewLodChain.plan,refine_selection,names_levels,MODES).ExportRun.from_taskspops the LODs mode (As Authored, the default, or Most Detailed Only) and armsprepare_lodswith it on every export, as it arms the take split on every FBX write; a preset turning the retired Check Geometry LOD Suffix row off is told once that every export now reads the groups.LodChain.planis the one decision both hosts apply (the valid groups, the levels the write leaves out, the report lines); a host only lists its nodes, afternames_levels, a name scan that spares a scene with no level-named node any further work.refine_selectionserves writers that carry each selected node's whole subtree (Maya's FBX and USD export): a selected node above a dropped level is replaced by its children, so Most Detailed Only holds in every format with no edit of the written file (measured: Maya's FBX export of a selected leaf writes its ancestors and their LOD group, never its siblings).- Test(s):
test_export_profile.pytest_the_lods_row_is_a_mode_that_arms_prepare_lods,test_the_lods_table_names_each_mode,test_prepare_lods_filters_the_export_set_before_any_check_reads_it,test_a_saved_lod_suffix_check_turned_off_is_said_once(red), six exact-task assertions gainedprepare_lods, 81 passed;test_lod_chain.pyPlanTest(5) andRefineSelectionTest(3), red: no such methods.
- Test(s):
- 2026-11-10 -- Added
LodChain: the one level-of-detail convention every carrier writes (file_utils/lod_chain.py, root export). A LOD group is a node<base>whose children<base>_LOD0..nare its levels, most detailed first, at most 8: Unity's_LODXnames, read in any case, with Maya's and Houdini'sLOD_<n>containers read and noted (Unity reads neither).groups()reads plain node values into groups with their blockingissues(no LOD0, a gap, a level named twice, more than 8, triangles not falling, no node of their own) and informationalnotes;lone_levels(),lower_levels(); switch heights per level with the cull last (default_heights,check_heights), and FBX's percent thresholds (to_fbx_thresholds/from_fbx_thresholds). The base of the LOD pipeline (FBX LodGroup, glTFMSFT_lod, USDLODvariant set).- Test(s):
test_lod_chain.py(new, red: no module), 22 passed.
- Test(s):
- 2026-11-10 -- A launched app no longer inherits a layered environment's packages (
core_utils/handoff/app_handoff.pyHandoffBridge.child_sys_path).uv run --with(how the MCP servers start) runs an ephemeral environment whose.pthfiles add the workspace venv and uv's cached archive, outside every prefix of the running Python, so they were handed to every instance the tooling launched, ahead of the host's own: an owned Blender 5.1 (Python 3.13) could not import its ownpxr(DLL load failed on the venv's 3.11 build) and mayaUSD's modules died on the venv's USD 26.05. An entry inside asite-packages/dist-packagesfolder or a virtual environment (a folder holdingpyvenv.cfg) is now dropped too; repo roots still pass. Tests:test_bridge125 passed; the downstream bridge suites (uitk, mayatk, blendertk, tentacle) passed. - 2026-11-11 -- New:
LightRig, the light rig engine both hosts bind: save, compare, swap and restore named rigs over aLightRigHostadapter, withPureLightRigHost(a scene in memory) as its default (core_utils/engines/lighting/light_rig.py;__init__.pyDEFAULT_INCLUDE;light_rig_payload.pythe payload's world is Y-up). mayatk's engine moved here unchanged: the canonical form,diff/compare/fingerprint, thelight_rigsrecord, apply / swap back and their snapshot, and the library verbs. A binding setsHOSTand its tables (HOST_TYPES,HOST_SETTINGS,HOST_FIXTURE_MODES,DIFFUSE_VISIBILITY,KELVIN_ATTRS); the neutral tables are built when the binding is defined, and one that leaves a contract name unmapped fails it. A neutralgobois the host's (LightRigHost.gobo_filter); a light the host cannot build is reported inskipped.- Test(s): new
test_light_rig.py(18: the binding's tables and their KeyError, kelvin and fixtures through a binding's names, save / applied / edited, apply and swap back toggling, swap back with nothing or several groups, an unbuildable light reported, the parent kept, carried sections, compare's defaults and override-only plugs, a follower yielding);test_light_rig_payload.pypasses;surface_snapshot.jsonadds the three names.
- Test(s): new