- 2026-10-19 -- Scene Exporter: a preset saved before today's row change says what its retired rows no longer do, and a read-only destination is called read-only (
core_utils/engines/scene_export/export_profile.pynew privateExportProfile._retired_row_notes;scene_exporter.pynewSceneExporterBase._note_retired_preset_rows;file_utils/_file_utils.pyFileUtils.describe_lock). The preset loader skips keys no widget takes, so a released preset's retired rows were dropped silently: a check whose fix it leaves off (turn the fix on to keep the check) now warns, and a fix now checked after it runs, a Baked Reflections level that no longer ships, or a writability check that runs anyway is said at info.describe_locknamed a read-only file "in use by another process".- Test(s):
test_export_profile.pyTestRetiredPresetRows(7) andtest_scene_exporter.py+1 (red: AttributeError);test_file.pyread-only description (red first).
- Test(s):
- 2026-10-19 -- A retired Scene Exporter row's saved panel state is said once, the writability check proves the folder, and retired class constants warn (
file_utils/_file_utils.pynewFileUtils.describe_unwritable;core_utils/deprecation.pynewDeprecation.class_attribute;scene_export/scene_exporter.pynew private_note_retired_panel_state/_saved_widget_values;export_profile.pynew private_retired_row_keys). The panel's persisted widget state dropped retired rows as silently as an old preset did (a saved Baked Reflections level, a check its fix leaves off): it is now said once from the same rule, then those keys are removed.describe_unwritablecreates and removes a probe file in the nearest existing folder, sinceos.accessreports an ACL-denied folder writable.BAKED_REFLECTIONS_OPTIONS/BAKED_REFLECTION_LEVELSwarn when read (removed in 0.17.0) through the new generalDeprecation.class_attributedescriptor.- Test(s): pythontk retired-panel-state checks,
describe_unwritable(a realicacls /denyfolder + a mocked refusal),class_attribute(class and instance reads, dataclass caveat) -- each red first; 383 passed with the surface snapshot (addsdescribe_unwritable).
- Test(s): pythontk retired-panel-state checks,
- 2026-10-19 -- A saved retired RegEx field really migrates (
core_utils/engines/scene_export/scene_exporter.pynewSceneExporterBase._migrate_legacy_regex). It folds a savedtxt002/<signal>into the Output Filename's saved pattern throughExportProfile.fold_legacy_namingbefore the panel restores it, removes the legacy key once the fold is stored, and drops a blank one; both hosts' copies (read under the wrong key, folded into the widget where the restore overwrote it) are gone.- Test(s):
test_scene_exporter.py+3 (red: 3 failing; then 32/32). Full runner 7990 OK.
- Test(s):
- 2026-10-19 -- The Scene Exporter panels drop the rows with one right answer: a fix row arms the check that verifies it, the writability check runs with no row, and Baked Reflections retires (
core_utils/engines/scene_export/export_profile.pynewExportProfile.VERIFYING_CHECKS/ALWAYS_CHECKS,run_config;GLB_LIGHTING_ROWSnow empty;glb_options/glb_defaultswithout the row;baked_reflections_default,ExportRun.baked_reflection_levelandExportRun(baked_reflections=)deprecated, removed in 0.17.0;docs/webxr_preview.md,net_utils/README.md).run_configarmscheck_mangled_names,check_duplicate_materials,check_sheared_local_transforms,check_floating_point_keysandcheck_untied_keyframeswhenever their fix runs, as it already armed the two texture checks, so what a fix could not repair still stops the export and a fix turned off takes its concern with it; a panel that still offers the check row keeps its say.check_output_writableis added to every panel run and survives Override Checks: a held destination fails the write whatever is overridden. Baked Reflections' default was the lighting recipe's own level, already right (each texel's reflection scaled by its bake, a carried probe reflected whole), so the deliverable publishes the recipe unchanged;from_tasksdrops an older panel's row with a note, andExportRun(baked_reflections=)still publishes its level, warning, until 0.17.0.GLB_LIGHTING_ROWSstays, empty, for a published extapps that builds its Lighting section from it.- Test(s):
test_export_profile.pyTestRunConfig+5 (a fix arms its check, off arms none, an older panel's check row wins, every verifying check is scheduled after its fix, writability with no row) and the override test keeps the preconditions;TestBakedReflectionsrewritten for the retirement (4);TestGlbOnlyProducerwithout the row;test_preview_server.pytest_a_push_publishes_the_lighting_recipe_whatever_a_retired_row_says. 66 passed; the preview test passed alone.
- Test(s):
- 2026-10-19 -- Second pre-release review: the FBX deliverable keeps every user texture, its record contract describes the highlight copies, and the highlight pass reports on the host's log (
file_utils/mesh_convert/fbx_media.pyFbxMedia.drop_host_lighting_media, new private_remove_objects/_video_file;core_utils/engines/scene_export/scene_records.pyLIGHTMAPS.assembly;_highlights.pyembed_fbx_highlight_probes(logger=),_mesh_convert.pyfinish_fbx_deliverable). The FBX SDK writes a file's bytes once, in the firstVideonaming it, so dropping a StingrayPBS environmentVideocould strip the bytes of a user texture (or a probe) on the same file: aVideowhose file a keptVideoor a scene record names now stays, wired to nothing. Thelightmap_metadatacontract shipped in every deliverable describesprobe.highlights(eachmapa light layer added to the probe, withreceivers/objects) and says a reader using the copies lights none of'lights'-- following the old text lit the highlights twice. A highlight pass that cannot run (no FBX2glTF) now says so in the run log.- Test(s):
test_fbx_media.pytest_a_file_another_video_or_a_record_names_keeps_its_bytes(red first);test_scene_records.pytest_the_lightmap_record_describes_the_probes_highlight_copies;test_mesh_convert.pyTestFinishFbxDeliverable.test_the_highlight_pass_says_its_outcome_where_the_host_logs(red first). 814 passed across the five suites.
- Test(s):
- 2026-10-19 -- The light rig contract states its units: lengths in centimetres, angles in degrees, whatever the host scene's (
core_utils/engines/lighting/light_rig_payload.pyLightRigPayload/LightRigLightsummaries;pos(was "the file's units"),aim,scale,radius,existinghelp); the published JSON Schema follows.- Test(s):
test_light_rig_payload.py+1 (red:pos's description).
- Test(s):
- 2026-10-19 -- A bake records the rule that chose its lights, and each map is judged by its own (
core_utils/engines/scene_export/bake_baseline.pyBakeBaseline:@rule,lighting_state(rule=)/lighting_digest(rule=),assess(lighting=callable), newlighting_rules,lights_used). The rule is the three lighting switches plus the Scope's light ids (None when every scene light lights the bake); a lighting recorded without a rule reads as changed once, then settles.@settingskeeps GI depth/bounces only.- Test(s):
test_bake_baseline.pyper-rule staleness, a rule-less record settles after one change,TestLightingRecord(red: 4 failed; 43 passed).
- Test(s):
- 2026-10-19 -- An FBX-only export or a Unity-bridge send builds its highlight copies from the FBX without its takes (
file_utils/mesh_convert/_highlights.pynew private_fbx_static_scene,_fbx_highlight_copies;fbx_media.pyFbxMedia.drop_takes(names=None)drops every take). The same copies to the byte (the full build places nodes at their rest transforms, which the takeless file keeps), in a fraction of the time: production assembly 1069 s -> 135 s. A run that already built its GLB still reads that GLB and converts nothing; a failed copy or conversion warns on the host log and leaves the deliverable as written.- Test(s):
test_fbx_media.pytest_no_names_drops_every_take;test_mesh_convert.pyTestGlbHighlightProbes+4 (the converter gets a takeless temp file; a copy that cannot be written leaves the deliverable; copies with and without takes identical through real FBX2glTF; a built GLB is reused) -- red first. 870 passed across the mesh suites.
- Test(s):
- 2026-10-19 -- A hidden run honours a cancel (
core_utils/app_launcher/_hidden.py_run_hidden;_app_launcher.pynew_AppLauncherInternal._cancelled, shared by the streamed and hidden paths).AppLauncher.run(hidden=True)checks the activeCancelScopewhile it waits: a cancel ends the hidden app's whole process tree at once and raisesOperationCancelled, instead of waiting out the timeout. An app that already exited is never reported cancelled.- Test(s):
test_app_launcher.pyTestRunHidden.test_a_cancel_ends_it_all_and_returns_at_once(pulled and pushed cancels; red: both ran into the 30 s timeout). Full runner 7917 passed.
- Test(s):
- 2026-10-19 --
StaleReport.summarysays what a lighting change stales (core_utils/engines/scene_export/bake_baseline.py; newStaleReport.baked, filled byBakeBaseline.assess). "the whole room" only when every baked object is stale, else "N of M baked objects".- Test(s):
test_bake_baseline.pytest_the_lighting_clause_says_what_it_stales(red first).
- Test(s):
- 2026-10-19 -- An FBX deliverable reads as its GLB twin does: one post-write sequence for every host (
file_utils/mesh_convert/_mesh_convert.pynewMeshConvert.finish_fbx_deliverable;_highlights.pynewMeshConvert.embed_fbx_highlight_probes;fbx_media.pynewFbxMedia.record,set_record,split_pivot_instances,drop_host_lighting_media). Measured on the WR2026 office deliverables in Unity against WebXR: the FBX carried the reflection probe without the highlight copies the GLB ships, so Unity's metals missed the bake's lights; an instance whose rotate pivot sat 131.44 cm below its siblings' (WALL_D) imported exactly 1.3144 m low, Unity moving the shared mesh by another instance's pivots; and StingrayPBS's environment cubes and BRDF table rode in, extracted where nothing reads them. The sequence, each pass guarded: the probe's copies first, from the file as a GLB export reads it; then a geometry gets a copy for each further set ofFbxMedia.GEOMETRY_PIVOTSamong its instances (the offsets, which the node keeps, do not count -- measured), the textures onlyHOST_LIGHTING_SLOTSsample leave with their media; the copies are the export's GLB's byte for byte, or those of a GLB built from the FBX the same way, the record's probe listing each and the objects it shades (probe.highlights: [{map, receivers, objects: [{name, hierarchy}]}], a mesh node under the copy holding most of its triangles), and every file the records name is embedded (DEPENDENCY_FIELDSnames the copies). Fixed on the way: the copies' surface samples were drawn in the order the triangles were stored (_highlight_samples), so the same surfaces stored another way drew another set -- an FBX-only export's copies differed from the GLB export's by 30% of a copy's texels (its pipeline strips takes and downsizes before converting, which moves FBX2glTF's triangle order); each group's triangles are now drawn in one canonical order (_canonical_triangles), and a build of the FBX draws the GLB export's copies byte for byte.FbxMedia._shift_definitions,_taken_idsand_replace_propare shared helpersembedanddrop_apparatusnow use too; theMeshConvertmodule docstring lists the_highlightsmixin.- Test(s):
test_fbx_media.pynewRecordTestCase(3),SplitPivotInstancesTestCase(3) andDropHostLightingTestCase(2), red first (no such method);test_mesh_convert.pynewtest_an_fbx_deliverable_ships_its_glbs_copies,test_without_a_glb_the_copies_come_from_a_build_of_the_fbx,test_a_record_without_lights_ships_no_copies,test_a_node_goes_under_the_copy_holding_most_of_its_triangles,test_samples_are_the_geometrys_not_its_storage_order(red first) andTestFinishFbxDeliverable(2). On the production exports (fresh Maya, Unity preset):WALL_DandBASEBOARD_Fsplit andWALL_A/WALL_Bstill share one mesh; 9 textures and 6 files dropped; the FBX+GLB runs' three copies byte-identical to the GLB's, the FBX-only run's three drawn from its own build.
- Test(s):
- 2026-10-19 -- A Scene Export that succeeds ends with an
Open containing folderlink (core_utils/engines/scene_export/scene_exporter.pySceneExporterBase._close_file_logging). The run's last line carries what to do with the result: the folder the deliverable landed in (action://open, which both hosts'UiUtils.dispatch_log_linkalready handle) and, when a GLB shipped, theOpen <name> in WebXRlink on the same line (·-joined). A run that failed or was cancelled links no folder. Both hosts get it through the shared closer.- Test(s):
test_scene_exporter.pynewtest_a_successful_run_ends_with_a_link_to_its_folder(red first: no folder link); mayatktest_a_glb_export_ends_with_a_link_that_opens_it_in_webxralso dispatches the folder link throughUiUtils.dispatch_log_link(fresh mayapy: passed).
- Test(s):
- 2026-10-19 -- The bake's lights reach a GLB as highlights drawn into copies of its reflection probe, never as lights (
file_utils/mesh_convert/_highlights.pynew:MeshConvert.highlight_probe_layers,lights_in_metres(was the unreleasedlightmap_web_lights);apply_glb_lightmapsembeds them aslightmap_web.probe.highlightsand writes nolightmap_web.lights;img_utilsnewImgUtils.encode_hdr_radiance_pixels; viewerkernel/probe.jsbuilds the copies and puts each on the materials it names,kernel/baked_lights.jsandviewer.lighting.setBakedLightsremoved,relight({highlights});LookCapture.relight(lights=)draws them as the export does; the sidecar'sRENDERING_POLICYstates the copies). The 10-17 live lights cost the frame a fifth (4K, desktop GPU: +0.18 ms over the bake alone) in a deliverable meant to be fully baked. A light ON the probe's box reflects where it hangs -- why a bright office's ceiling panels lit its metals from the probe alone -- but one hanging inside the room is read as if on the wall behind it, so a copy draws each light where box projection shows it true for a group of receivers: a texel whose ray leaves the box at B shows what the group's focal point F sees of the light along F -> B. The shell (baked surfaces on the box's faces) reads the probe as captured; an unbaked asset gets a copy of its own (F at each light's irradiance centroid on it, the irradiance matched); the rest of the interior shares one (F at each light's highlight hot spot as the start camerauser_pos-- else the probe point -- sees it, the highlight matched). Each copy's materials reflect it as their own environment: one lookup, as every material reads the probe, so no shader cost; GPU memory ~9.5 MB beside a desktop probe. Measured on SOLDERING MOODY_LIT against its 15 live lights: the magnifier, puck, trays and button box read as live (signed off by eye); the pipeline's frames sit within 0.08 display codes of the approved prototype in four views; the GLB grew 0.1 MB and the load spends 49 ms decoding and 118 ms prefiltering its three copies.MathUtils.smoothstepeases an array element-wise (a spot's cone over every surface sample at once); a number still returns a float. The pass reuses it andMathUtils.safe_normalizerather than private twins.- Fixed on the way: a light smaller than a texel as its focal point sees it fell between every ray and was not drawn (the smallest drawn size is now one texel, energy kept); a copy narrower than 64 texels -- below the 16-texel cube face three.js' PMREM prefilters -- came back as no map and its materials silently reflected the plain probe (now skipped and reported in the load's issues).
- Test(s):
test_mesh_convert.pynewTestGlbHighlightProbes(10: the shell gets no copy; an unbaked asset's parts share one, a far part its own; a light on the box keeps scale 1; a hanging light is drawn where box projection reads it from its focal point, and along its own direction for a distant probe; a spot whose cone misses gets nothing; the start camera is the eye; metres and candela; the lightmap pass embeds decodable copies and no light; red first on a light smaller than a texel),test_the_bakes_lights_never_ship_as_lightsand the malformed-light regression reworked;test_img.pynewtest_radiance_pixels_keep_their_channels_and_range;test_math.pynewtest_smoothstep_eases_an_array_element_wise;test_preview_viewer_live.pynewtest_a_highlight_copy_is_the_probe_its_materials_reflectandtest_a_highlight_copy_too_narrow_to_prefilter_is_reported(the first red first, on its fixture's too-narrow copy);test_preview_server.pytest_a_baked_material_takes_the_environment_as_specular_onlypins the copies' level as the one material level the page writes.
- 2026-10-19 -- A GUI app with no headless mode runs where no one sees it, and leaves nothing behind (
core_utils/app_launcher/_hidden.pynew:AppLauncher.run(hidden=),AppLauncher.can_hide;_processes.pyAppLauncher._new_job/_end_job, now alsospawn's lifetime-job recipe). RizomUV and Marmoset Toolbag have no headless mode, so every bridge run flashed a window.hidden=True(Windows) creates the app on a private desktop of the user's window station -- the same monitors, so it opens and saves its windows where it always does -- inside a kill-on-close job of its own from its first instruction, inheriting its own std handles and nothing else. The app's exit, the timeout, an error or Ctrl+C in the caller, or the caller killed outright ends the app and everything it started. Measured through this code: all 24 runnablemayatk/test/rizom_headless_probe.pycases pass in 140 s with no RizomUV window on the user's desktop and its saved frame unchanged; a Toolbag 5 roundtrip bake throughMarmosetEnginewrites all 5 maps pixel-identical to a visible run's (max diff 0) with its window prefs unchanged; a Qt window's saved geometry is byte-identical after a hidden run. Not offered elsewhere (a no-op, ashide_windowis;can_hide()says which, so a caller tells its user what really happened): a virtual X display is not the user's screen -- measured under WSL's Xvfb, Qt refuses to restore a geometry saved on a screen more than 25% wider or narrower, the app saves its defaults, and its next normal launch opens small in a corner. NotSW_HIDEeither: RizomUV never finishes its script under it. Nor forlaunch: an app outliving this process on a desktop no one sees could never be closed. A hidden run waits in short steps, not for end of file, so a helper the app left holding its output pipe ends with the app instead of holding the run to its timeout (a plain run still waits). On the way: a streamed run (on_output) whose callback raised, or that was interrupted, left the child running unwatched; every way out now kills it.- Test(s):
test_app_launcher.pynewTestRunHidden(9: a private desktop with its pipes, env, cwd and exit code in all three run shapes; its window never on the user's desktop; a timeout, an error, Ctrl+C and a streamed error end the tree; a helper left behind ends with the app; a caller killed outright takes the tree; no other handle inherited; a saved window placement unchanged; a no-op where it cannot hide) andTestRunStreaming(1, red first: the child outlived the error). Each guarantee is red against a plainPopen(7 failures, the timeout one at 120 s), without the handle list, and withoutpollending the job.test_app_launcher+test_script_run117 passed, 12 skipped.
- Test(s):
- 2026-10-19 -- Two preview log lines its caller already reports are debug now (
net_utils/preview/bridge.pyPreviewBridge.share,deliverer.py). "Sharing the preview at ..." repeated the WebXR panel's own share line (and, logged from the share's thread, landed after it); "No scene sidecar requested." repeatedPreviewBridge.sidecar_summaryand was said for file pushes, which never carry a sidecar. The link isshare()'s result and the sidecar outcome is the push result'ssidecar_requested/sidecar.- Test(s): pinned from the consumer --
extappstest_webxr_preview.pytest_every_launch_link_is_followed_by_a_copy_linkasserts the pane never shows "Sharing the preview at";test_preview_server.py+test_share_tunnel.py326 passed.
- Test(s): pinned from the consumer --
- 2026-10-19 -- Pre-release review fixes: GLB lightmaps, probe placement, UV combine/transfer and articulation limits (
file_utils/mesh_convert/_lightmaps.py,fbx_file.py,geo_utils/probe_placement.py,geo_utils/uv/combine.py,geo_utils/uv/transfer.py,geo_utils/articulation/plan.py,img_utils/_color_space.py).- A malformed light in the bake record no longer costs a GLB its lightmaps:
MeshConvert.lights_in_metres(thenlightmap_web_lights) leaves that light out and the maps andlightmap_webmanifest ship (the guard used to log "GLB lightmaps skipped" after the materials were already rebound). MeshConvert.strip_glb_baked_lightsfinds a natively exportedlightmap_web(first scene's extras, a JSON string, where the viewer looks first), so such a GLB no longer keeps the bake's lights on top of its lightmaps; the manifest lookup is one private_lightmap_web_slotshared withembed_glb_lightmap_probe.ProbePlacementresolves a tie between two meshes by ray order, not set hash order, so one scene places its probe the same way in every process.- New
UvCombine.island_shells: one mesh's arrays as one shell per UV island, grouped with one stable sort (both hosts' Texture Combine gathers used one full scan per island, twice per combine);CombineInputsgainsshell_points. UvTransfer.save_mapno longer passes Pillow's deprecatedmode=(removed in Pillow 13).ArticulationPlan.limits_textshows six significant digits: editing a Limits cell re-applies every channel it shows, so a limit taken from a pose (-37.84623) or a long slide (1234.5) no longer rounds on the next edit.- Look Match engine and its job queue (
core_utils/engines/lighting/,net_utils/preview/look_capture.py,file_utils/job_queue.py,core_utils/presets/store.py): re-capturing a layer no longer deletes its stored bake (LookCapture.capture_layerhandedLightLayers.addpaths inside the folder it clears first: FileNotFoundError);LightLayers.addreturns the layer's.npyand writes it atomically; an exclusion on one side of a region no longer cuts the other side's (shared, cached) painted mask;LookScorereads one-value statistics under numpy 2 and stays readable after its target's regions change (it scores a snapshot); a quad with a differentgridper side is refused at construction (LookRegion.GRID);painted()assigns colours once per file; the sheet's good/bad colours followLookScore.summary's rule (newMeasureResult.miss);JobQueue.waitno longer fails on a crashed worker's stale heartbeat while the job is still queued, a momentary Windows lock on a job file no longer endsserve, and ids sort in submission order within one clock tick; the capture service prints a non-ASCII path (PYTHONIOENCODING), and--from/--swapreach it as absolute paths;PresetStore.adoptretries after a copy that failed part way; the JSON writers go throughFileUtils.write_json;LightPredictor.basis(unused, never released) removed. - WebXR viewer (
net_utils/preview/kernel/relight.js,features/look_capture.js):viewer.lighting.restore()takes down a probe a relight put up when the file carries none, so "the file as it ships" is no longer lit by the swap's room; a material a relight leaves out wears its own lightmap again instead of the previous swap's freed texture;restoreLightingwaits for the file's probe before returning. - A hidden run (
AppLauncher.run(hidden=True)) whose environment holds an emoji or any other character outside the Basic Multilingual Plane starts instead of raisingValueError: string too long(the environment block is sized in UTF-16 units), and an emptyenvgets its full double terminator. - Probe highlight copies (review): they light phones too -- where the GPU cannot filter 32-bit floats (
OES_texture_float_linear) the probe and its copies go up as half floats instead of sampling black (kernel/probe.js); a restore outrun by a newer push leaves that push's probe up and frees the copies it decoded (kernel/relight.js);MeshConvert.highlight_probe_layerssamples by area alone, at most 50,000 per group (a dense interior drew at least one sample per triangle: ~30 s per rect light per million, weighted to its tessellation), a light just off a wall no longer drops every copy (_proxy_radiusclamped), and a malformed light is left out (validated throughlights_in_metres);LookCapture.manifestreadslightmap_webwhere the viewer does (MeshConvert._lightmap_web_manifest), so a natively exported template relights. - Internal:
FbxFile._object_nameshared byobject_namesanduser_properties(named=True);test_shots_core.TestPlanOrderno longer re-runsTestPlanReorder's 8 tests; test temp dirs intest_app_launcher.pythroughTempArtifacts, and its shortcut test compares the link's target case-insensitively (the shell hands the drive letter back canonical, so a suite launched fromo:\failed). - Test(s):
test_mesh_convert.py+2 (malformed light; native manifest, red first);test_probe_placement.py+1 (tie, red: 3 != 5);test_uv_combine.pyTestIslandShells+3;test_articulation_plan.py+1 (red first);test_preview_viewer_live.py+2 (restore takes the probe down; a left-out material wears its own map; both red first); the Look Match and job-queue fixes +15 acrosstest_look_capture,test_light_layers,test_look_target,test_look_measures,test_job_queue,test_look_sheet,test_preset_store(each red first);test_app_launcher.pyTestRunHidden.test_an_emoji_in_its_environment_reaches_it(red first); highlight copies +6 (test_a_highlight_copy_lights_a_device_that_cannot_filter_float,test_a_restore_outrun_by_a_new_model_leaves_it_alone,test_samples_follow_area_not_tessellation_and_stay_bounded,test_a_light_just_off_a_wall_is_drawn,test_a_malformed_light_is_left_out_of_the_copies,test_the_manifest_is_read_where_the_viewer_reads_it), each red first. Partition suites 1961 passed, 1 skipped; uv suites 160 passed.
- A malformed light in the bake record no longer costs a GLB its lightmaps:
- 2026-10-18 -- What the Look Match panel stands on: the session file declared once, a bake's scene save recorded, fixture modes and one rig library for every host, a worker that ends when no one wants it, and the engine's region summary, region close-up and capture-service start (
core_utils/engines/lighting/look_match/session.pynewLookSession(+LookCaptureSettings,LookLoopSettings,LookWorker;LookMatch.open,LookLoopandLookCapture.open_sessionread it);lighting/light_layers.pynewLightLayerScene(of,matches),LightLayerInfo.scene;lighting/light_rig_payload.pynewLightRigFixture(MODES),LightRigPayload.fixtures,LIBRARY,library;look_match/loop.pynewLookLoop.stale_layers,rebake,worker_stale, thematchargument,stalecounting an older scene save;look_match/measures.pynewLookScore.summary(BRIGHTNESS,COLOUR_WORDS);look_match/sheet.pynewLookSheet.region_row,difference(was_difference);look_match/target.pynewRegionShape.drawn/from_points(rect, ellipse, polygon, line),LookRegion.drawable/spec_from_points,LookTarget.add/remove/rename;net_utils/preview/look_capture.pynewLookCapture.spawn,capture_bakecarries the scene record;file_utils/job_queue.pynewJobQueue.lease/leased;core_utils/presets/store.pynewPresetStore.adopt;core_utils/app_launcherAppLauncher.launch(log=);lighting/light_predictor.pyLightPredictor.linearwithout a temporary per layer). The session file was a docstring three readers each parsed by hand; it is a typed contract, and a path spelledcache:lives in the session's own per-user cache folder (derived data off a synced drive). A bake now says which save of the scene it shows, so a layer an older save made is stale (LookLoop.stale_layers) and bakes again from what the store recorded (rebake, a job per baker setting). A light fixture's three ways of lighting a room -- glow, emissive, lamp -- are rig data a host resolves (the BACKLOG decision: a mode per fixture, exclusive because one fixture casts one light). The rig library is pythontk's, unnamespaced (user_config_root()/light_rigs), so a look match saves where Maya applies; rigs in mayatk's old folder are copied in once (PresetStore.adopt, whichWorkspaceTemplates' migration now uses too). A worker a caller leases ends once the lease lapses with nothing queued -- idle, or its caller gone -- and a lapsed lease left by a caller that died before its worker came up is dropped at start.LookScore.summaryreads each region as a person does (within tolerance, brightness on screen -- display values, not light -- and a colour word);LookCapture.spawnstarts a fresh service on a port the OS picks and returns its address once it serves (never one already running). Measured on the MOODY session: a mix's preview against its WebXR frame is 0.996 over the frame and 0.99-1.01 on matte regions, metals 0.84-0.90 (BACKLOG); the earlier "emission over-predicts 3x" lead is refuted. A region drawn in pixels becomes its spec through its shape (from_points, the inverse ofoutline; a quad's grid is chosen, so it is not drawn), and a target adds, renames and removes regions by name, the focus following.LightLayerInfo.json_schema()raised (itstargetdeclarednested=against anOptionalannotation): fixed.LookSession.readof a file whose root is not a JSON object raised a bareTypeError(or aValueErrorabout dict sequences): now the session refusal.- Test(s):
test_look_session.pynew (5);test_light_rig_payload.py+4 (fixtures, the library and its migration, the scene record, every contract publishes its JSON Schema -- red:SchemaErroronLightLayerInfo);test_job_queue.py+4 (lease: idle end, a queued job outlives it, a dead caller's lease dropped, withdrawn);test_preset_store.py+2 (adopt);test_look_loop.py+4 (an older save bakes again;rebakefrom recorded payloads, a job per setting; a refusal; an open session and a slow worker);test_look_measures.py+1 (summary);test_look_sheet.py+2 (region_row,difference);test_look_capture.py+3 (spawnreads the announced port, says why it exited; the scene record reaches the store);test_look_match.py+1 (cache:layers);test_look_target.py+3 (drawn shapes, refused ones, add / rename / remove);test_look_session.pya non-object file refused (red: the dict error);test_app_launcher.py+1 (launch(log=)); surface snapshot updated (LightLayerScene,LightRigFixture,LookSession).
- Test(s):
- 2026-10-18 -- An RPC service started on port 0 announces the port the OS picked (
net_utils/rpc/plugin_core.pyRpcPlugin.start/serve; the four staged_rpc_core.pycopies re-synced). Both printed the port they were asked for, so a service asked for any free port said:0and a launching process reading the line could not find it;servealso flushes the line, which a parent reading a pipe would otherwise wait on.- Test(s):
test_plugin_core.pynewtest_serve_announces_the_port_it_bound(red:serving on http://127.0.0.1:0). 50 passed;sync_rpc_core.py --checkandextapps/test/test_vendor_sync.pyclean.
- Test(s):
- 2026-10-18 -- Enter VR on a hybrid-GPU laptop no longer leaves the headset black (viewer
kernel/xr.js:renderer.xr.setSessionwrapped; the hint says when a session starts and ends; the time-out names the VR permission prompt). Measured on Edge (Iris Xe + RTX 3070 Ti, Virtual Desktop): started on the integrated GPU, the first press's session was granted, thenmakeXRCompatiblerejected (InvalidStateError) while Chromium relaunched its GPU process on the RTX. The context came back a second later, but three'ssetSession, which takes the session before that await and catches nothing, had already given up, so the session stayed open with nothing drawing it. Started on the RTX (Windows' per-app High performance), the same press presented at once (859 frames in 20 s). A session whose setup fails is now ended; when the context was lost on the way and the click still counts as a gesture, the page presses Enter VR again once, after the context is back; otherwise it says to press again, naming the Windows setting. A failure with no context loss is not retried (it would only repeat) and the hint names its reason, as the old unhandled-rejection report did. Verified on the headset: started on the Iris Xe, the press was rejected, the context came back 1.0 s later, and the page re-entered by itself and presented. On the way: "Starting session…" no longer outlives a session that started nor follows a press of EXIT VR (which also armed the time-out), and a request left pending by the browser's VR permission prompt no longer reads as "is Link running?".- Test(s): live
test_a_session_whose_setup_fails_is_ended_and_entered_again(one faked device,FAKE_XR: the GPU moving once -- the retry presents -- or every time, with and without a gesture, and a failure with no context loss; red first: the session was never ended); livetest_a_session_that_sets_up_presents_and_says_so(setup run to the end and out again; red first: the guard's unbound call failed every entry at three'sthis.setFoveation).
- Test(s): live
- 2026-10-18 -- The WebXR page names a VR runtime switched under the browser, and the cure (
net_utils/preview/server.pymanifestxrRuntimeis now the active runtime's name, false for none -- newPreviewServer._xr_runtime_name; viewerkernel/xr.js). Connected through Virtual Desktop, the page kept saying "No headset presenting — start Link or SteamVR": Chromium loads the active OpenXR runtime once per XR service and never re-reads it, and Virtual Desktop was the active runtime while Edge's XR service, started the day before, still held Oculus's -- unsupported for good, nodevicechangeto cure it, while a fresh Edge answered supported at once (measured). The manifest now names the runtime (its manifest'sruntime.name, else the file's stem), and a page that sees it change under it says which runtime the browser holds and to restart the browser; the plain hint names connecting Virtual Desktop beside starting Link or SteamVR (Virtual Desktop running but not connected to the PC offers no headset either -- measured), and the restart once connected (a page opened after the switch cannot see it).- Test(s):
test_preview_server.pytest_the_manifest_says_whether_an_xr_runtime_is_installed->test_the_manifest_names_the_active_xr_runtime(6 file shapes; red first); livetest_a_runtime_switched_under_the_browser_names_its_cure(red first: the hint never named the switch).
- Test(s):
- 2026-10-18 -- The look-match engine is one subpackage with seams: region shapes, layer kinds and command lines plug in as data, its capture service speaks the shared RPC contract, and the rig payload and
layer.jsonit trades with a DCC are declared once (core_utils/engines/lighting/look_match/--_look_match,target,measures,fit,sheetmoved fromlighting/look_*.py,loopfromnet_utils/preview/look_loop.py; newlighting/light_rig_payload.pyLightRigPayload/LightRigLight;lighting/light_layers.pynewLayerKind,LightLayerInfo,LightLayerTarget,LightLayers.register_kind/kind;look_match/target.pynewRegionShape,LookRegion.register_shape/outline/anchor/sample;core_utils/cli.pynewCLI.Command/CLI.dispatch;img_utilsnewImgUtils.luminance/kelvin_tint/sample_bilinear,core_utils/color.pyColor.REC709_LUMINANCE;net_utils/rpc/plugin_core.pynewRpcPlugin.serve,client.pynewRpcClient.for_url;net_utils/preview/look_capture.pynewLookCapture.client; removed, never released:LightPredictor.tint,LookCapture.request, the threeLUMAconstants,lighting/_look_match.main). Read against CODE_STANDARD: fivelook_*siblings were a namespace spelled into file names (now thelook_match/subpackage, themap_factoryshape); the engine depended on the preview package only throughLookLoop, which now takes an injected client and lives with the engine; the command line ispython -m pythontk.core_utils.engines.lighting.look_match(and.look_match.loop). One owner per concept: Rec.709 luminance weights were spelled four times (threeLUMAcopies andColor.luminance), a light's unit-luminance kelvin colour lived on the predictor though mayatk's highlight lights needed it, andImgUtils._bilinearwas called from outside its class. Seams where a dispatcher grew a case per variant: a region's shape answered for its pixels inLookRegionbut was re-derived for its outline and label inLookSheetand for its samples inLookScore(aRegionShapenow owns all four, so a new shape is a subclass plus one registration -- and a quad's painted or object-ID exclusion, which raisedKeyError: 'poly'because the quad drew cut-outs with the rect/ellipse/poly rasteriser alone, now cuts like any area); a layer's kind was a string compared in the store, the predictor, the fit andto_rig(LayerKinddeclares tint, ship, lights and how a rig carries it; a kind this version does not know reads as inert,LightLayers.UNKNOWN_KIND, rather than failing the store); four hand-rolled subcommand chains becameCLI.dispatchtables (LookMatch, LookCapture, LookLoop, mayatk'sLightLayerBaker; the stdlib-only execution-monitor sidecar is exempt by design). The capture service hand-rolled an HTTP server and anif op ==chain; it now registers its ops onnet_utils.rpcand serves with the newRpcPlugin.serveon the calling thread, because the browser it drives answers only the thread that started it (capture'sfromkey issource).RpcPlugin.stopcalled from an op deadlocked --shutdownwaits for the serve loop, which waits for the op -- and now hands the shutdown to another thread; the four staged_rpc_core.pycopies are re-synced. Contracts:LookMatch.to_rigvalidates its output againstLightRigPayload(both production MOODY rigs fit),LookCapture.capture_bakevalidates thelayer.jsonit reads againstLightLayerInfo(all 28 in the MOODY session fit).- Test(s):
test_light_rig_payload.pynew (4);test_look_target.py+2 (a registered shape end to end; a quad cuts out a painted marker -- red:KeyError: 'poly');test_light_layers.py+1 (a registered kind through store, prediction and rig);test_look_match.py+1,test_look_capture.py+1 (out-of-contract rig /layer.jsonrefused -- red with the checks off);test_cli.pynewTestDispatch;test_img.pynewTestKelvinTint,TestLuminance,TestSampleBilinear;test_plugin_core.py+2 (an op stops its own server -- red: hangs, rc 124;serveruns ops on the calling thread); capture and loop tests on the RPC contract (an unknown op no longer wakes the page). 696 passed across the touched suites;check_layers.py,sync_rpc_core.py --checkand the surface snapshot clean.
- Test(s):
- 2026-10-18 -- A look sheet's region names stay clear of its frame captions (
core_utils/engines/lighting/look_match/sheet.pynewLookSheet._label_origin,_tag,CAPTION_BAND). A region at the frame's top (a back wall's band) had its name clamped to the top edge, under OURS / REFERENCE; a name now goes inside its region when there is no room above the caption band, and the captions sit on the same dark plate as the tile labels (one_tag).- Test(s):
test_look_sheet.pynewtest_a_region_at_the_top_is_named_inside_it(red against the old clamp);test_look_sheet+test_look_match27 passed.
- Test(s):
- 2026-10-18 -- A captured emission layer keeps the weight a mix scales (
net_utils/preview/look_capture.pyLookCapture.capture_bake).layer.json'starget(mayatk'sLightLayerBakernow writes it: the material's emission weight and the value it was baked at) reaches the store, soLookMatch.to_rigbuilds the layer instead of listing it unbuilt.- Test(s):
test_look_capture.pynewtest_an_emission_bakes_target_reaches_the_store(red first);test_look_capture+test_look_match40 passed, 4 skipped.
- Test(s):
- 2026-10-17 -- PyPI metadata: a one-line summary, keywords, audience and topic classifiers, and Changelog / Issues links (
pyproject.toml). The keywords double as the repo's GitHub topics, andm3trik/scripts/sync_github_about.pyholds the GitHub About box (description, topics, PyPI homepage) to this file. - 2026-10-17 -- The deliverable records state their angle and time units beside their lengths (
core_utils/engines/scene_export/scene_records.pythe handoff sentence andLIGHTMAPS/AUDIO;geo_utils/articulation/record.pytand slide limits). A length stays in the file's own units withunit_scalebeside it -- the hosts now honour that for every record (mayatkUnits) -- and the handoff adds: an angle is in degrees unless its field says radians (a spot's halfangledoes), frames count at the record'sfps(the audio record now carries one). The articulation record names the file's units rather than "the DCC's". Generated types refreshed (m3trik/scripts/sync_scene_records.py).- Test(s):
test_scene_records,test_handoff_manifest,test_handoff_scope,test_scene_data_sidecar,test_articulation,test_articulation_plan,test_mesh_convertpass;sync_scene_records.py --checkin sync.
- Test(s):
- 2026-10-17 -- Reading a look by eye and by region, and a capture service that sleeps between requests (
core_utils/engines/lighting/_look_match.pynewLookMatch.readout,owners,solve,exposedand CLIreadout/owners/solve/tiles;look_sheet.pynewLookSheet.pair,LookSheet.tiles;net_utils/preview/look_loop.pywritescompare.png;net_utils/preview/look_capture.pynewLookCapture.idle; viewer APIviewer.setRendering(on)(kernel/ui.jssetRendering,kernel/api.js,features/look_capture.js)). Relighting a production room toward a reference, a fit's single cost kept pointing the wrong way -- it switched off the corner glows the reference plainly has, and chased rim highlights with the key -- so each round is now read the way a lighting artist reads it:pair/compare.pngput ours beside the reference whole and close up;tileslays candidate mixes, exact frames and each layer alone (auto-exposed byexposed) in one labelled grid;readoutgives every region's display colour beside the reference's (exact frames judged as themselves);ownersranks the lights by how selectively they light a region (the one to reach for);solvefinds the weight of one light that brings a region to the reference (a layer's weight is in its bake's units -- a narrow spot needed 53 where an area light needed 0.09). A mix entry'sspecular(LightMix.specular; a LookLoop role's"specular") is a layer's highlight level apart from its light -- Arnold'saiSpecular: it scales only the lights a deliverable ships for highlights (LightPredictor.lights), andLookMatch.to_rigwrites it on the rig's lights, whichLightRigsets asaiSpecular-- the same knob in the render and in WebXR (a production table's side spots glared on the wood at 1 and read as the reference's soft sheen at 0.25).LookCaptureanswers{"op": "file"}(file_frame): the template's own lighting, as it ships -- the baseline a side-by-side of every saved rig is held to. A rig carries the state its layers were baked under:LookMatch.to_rigputs the store stamp'smaterials(an office's emission off) in the payload beside itsexisting, and a LookLoop round records its rig'sbasein the stamp, so applying the rig to another scene sets that state there.LightLayers.validatenames a light layer whose bake recorded none of its lights (an exact frame ships none of their highlights: a production puck read 70% darker in the layers' frame than under the rig they made). A round whose bake state differs from the one the store recorded says so (report["base_changed"]: the store now mixes layers baked under two states).LightLayersre-reads its manifest before every change (_refresh): the capture service and a loop each hold the store, and a write from a stale copy dropped the other's stamp or layers. Fixed on the way: an emissive preview (setLighting'semissive) wrote each material's level and never put it back, so it leaked into every later frame and into the file's own lighting; each relight now starts from the materials' own levels. And the capture service stopped drawing while idle: a headless software-GL viewer drew every frame on about seven cores, and an Arnold layer bake beside it ran eight times slower (measured). A page freeze (CDPsetWebLifecycleState) left its GPU process drawing (measured), so the viewer's frame loop itself stops between requests and restarts for the next.- Test(s):
test_look_match.py+6 (readout, owners, solve, exposure, the four commands, a rig light's specular; red first),test_light_layers.py+3 (the specular scale; validate names an unrecorded layer; two writers on one store keep each other's changes, red first) andtest_light_predictor.py+1 (the specular scale),test_look_loop.py+2 (a role's specular reaches the mix; a round records its bake state),test_look_match.pynewtest_a_rig_carries_the_material_state_its_layers_were_baked_under,test_look_sheet.py+2 (pair,tiles),test_look_capture.pynewtest_a_service_sleeps_its_page_between_requestsandtest_the_files_own_bake_is_shot_as_it_ships, livetest_an_emissive_preview_lasts_one_relight(red first on the production template: 1.3% of the frame kept the preview) (red first);test_preview_server.pytest_the_viewer_plays_the_clips_a_deliverable_carriesanchors on the named frame callback. Live: the idle GPU process drew on 2.9 cores frozen and 0.01 with the loop stopped (a probe page).
- Test(s):
- 2026-10-17 -- Look match reads regions as masks, not boxes, and fits colour as well as level (
core_utils/engines/lighting/look_target.pynew area shapes{"mask", "color"}(a painted marker layer) and{"objects": [patterns]}(our side's object-ID render),LookTarget.painted/objects/paint_template,ids/base;look_fit.pyLookFit.run(kelvin=), Jacobian on threads;look_sheet.pyoutlines a mask by its edge;net_utils/preview/features/look_capture.jsnewids,LookCapture.shoot_ids, request / CLIids --target;LookMatch.fit(kelvin=), CLIfit --kelvin;LookLooproles'fit_kelvin, the rig'sfit.size). Boxes read background: on a production target the puck's warmth read 3.29 against the reference's 2.28 from the wood and mat inside its box, and 1.87 against 1.83 from its own pixels; a misplaced rim box held the fit's largest residual for three rounds. Our side now names objects (the viewer draws each mesh in a flat colour of its own -- exact, no background, right for any camera); the reference's side is a marker layer -- a transparent image of strokes, one colour per region, painted in any editor -- thatpaint_templatestarts from a target's current shapes (overlapping regions refused on one layer; a part goes on its own). A fit also moves colour temperature, in mireds, each layer tinted per evaluation at no extra cost; its Jacobian columns run on threads with float32 sums (a 17-role fit 487 s -> 176 s at the same size, 54-86 s at a 410x240 working size).- Test(s):
test_look_target.py+5 (painted and object regions, a shape with no target named, the paint template reads what it was drawn from, overlaps refused),test_look_fit.pynewtest_it_recovers_the_colour_temperatures_that_made_the_reference,test_look_sheet.py+1,test_look_capture.pynewtest_an_object_id_render_attaches_to_a_target(each red first).
- Test(s):
- 2026-10-17 -- Baked lights ship for the highlights a lightmap cannot hold (
core_utils/engines/scene_export/scene_records.pySceneRecords.LIGHTMAPSgainslights;file_utils/mesh_convert/_lightmaps.pynewMeshConvert.lightmap_web_lights,apply_glb_lightmapswriteslightmap_web.lights; viewerkernel/baked_lights.jsnew, lit at load from the manifest,viewer.lighting.setBakedLights). A lightmap holds each light's diffuse and no direction, so no highlight; a probe cannot stand in for a spot or point light. Measured on a production soldering table (2026-10-17): Arnold's render with every light's specular off matched the viewer's dark tray rims and dull puck; with it on, both read silver. The record now carries the lights the maps were baked from (scene units besideunit_scale), the applier writes them in metres and the viewer's units (candela for a point or spot, a rect's radiance as is), and the viewer lights them: a baked material takes their SPECULAR only -- its shader (the permanent baked patch, under the probe's) drops all direct diffuse, which is the bake's -- and an unbaked one takes them in full (the probe holds no spot or point light), scaled 1/pi as the bake is drawn. Lit that way they came within 0.86-1.38x of Arnold's direct specular on every desk region, with no fitted factor. They stay out ofKHR_lights_punctual: a viewer that does not know they are baked would light the scene twice. They cast no shadow yet (ledgered).LookCapturelights a bake folder's lights into its layer (capture_bake), so a prediction holds the highlights too.- Test(s):
test_mesh_convert.pynewtest_baked_lights_ship_in_metres_and_the_viewers_units(red first);test_look_capture.pynewtest_a_bake_folders_lights_light_their_highlights_in_the_layer(red first); the viewer's half measured live (the specular comparison above);check_js_typesOK.
- Test(s):
- 2026-10-17 --
LookLoop: one round of look matching from a rig by role, in one call (net_utils/preview/look_loop.py, rootDEFAULT_INCLUDE; CLIpython -m pythontk.net_utils.preview.look_loop iterate|status). A round was ten steps by hand. A rig file names the roles (each a light payload, its weight, colour temperature and whether the fit may move it);iteratebakes only the roles whose payload differs from the one their layer was baked from (a round that moves weights bakes nothing), captures them in theLookCaptureservice, fits the free roles, and writes the mix, the rig at the fitted weights (the next round's start), the predicted sheet, the viewer's exact frame and its sheet, and a report naming the worst regions. The bake worker (aJobQueuefolder) and the service are named by the session'sloopsection.- Test(s): new
test_look_loop.py(4: only changed roles bake; a round with nothing to bake fits and judges; a changed role bakes and captures before the fit; a missing worker is named).
- Test(s): new
- 2026-10-17 -- Dropping a rig's apparatus removes a bind pose it empties (
file_utils/mesh_convert/fbx_media.pyFbxMedia.drop_apparatus;_drop_pose_nodesreports whether it took every entry). Maya writes a bind pose per skinCluster, and an IK curve's holds the curve alone: the drop took the curve and kept its pose with no members, and Maya's FBX importer, handed empty bind poses, bound the skins that stayed at the wrong pose -- on a production assembly 6 of 7 wire looms re-imported 63-96 cm off in their bind world. Unpruned, or pruned minus the 7 emptied poses, all 7 were exact. An emptied pose now goes with its connections and itsDefinitionscount; a pose already empty in the source is left as written.- Test(s):
test_fbx_media.pyDropApparatusTestCase+1 (build_rigged_fbx(curve_pose=True); red first; mutant 1 FAIL).test_fbx_media64 +test_export_profile62 passed; blendertktest_scene_exporter364/364.
- Test(s):
- 2026-10-17 -- A black emissive switches the glow off instead of being skipped (
file_utils/mesh_convert/_materials.pyMeshConvert.set_glb_emissive). FBX2glTF glows a mapped emissive at 1.0 whatever the DCC said, so a material authored dark -- the zero colour mayatk / blendertkSceneStatenow send for a mapped emission at weight 0 -- left the converter's glow in place: a production room's ceiling panels lit WebXR at full while Maya had them off. The material now leaves with factor 0, its map and emissive strength removed, nothing embedded for it.- Test(s):
test_mesh_convert.pynewtest_a_black_emissive_switches_the_converters_glow_off(red first);test_preview_server.pytest_zero_factor_never_blacks_out_an_emissive_map->test_a_zero_factor_switches_the_emission_off_and_embeds_nothing(the contract changed; still no orphaned image).test_mesh_convert+test_preview_serverpass.
- Test(s):
- 2026-10-17 --
JobQueue: a folder of JSON jobs that one worker process takes in order (file_utils/job_queue.py, rootDEFAULT_INCLUDE). Work that has to run inside another process -- a DCC opened once on a scene that takes minutes to load -- without a network port anything else on the machine could answer or be answered by. The callersubmits andwaits (failing fast when the worker's heartbeat stops, not at its timeout); the workerserves, or -- in a host with its own event loop (a GUI DCC) --begin/step/stopping/end. A job that raises is recorded failed with its traceback and the next one runs; a job a dead worker left running fails at the next start; a second worker on a live queue is refused. A setup that raises stops the heartbeat and leaves the workerstoppedwith the error; awaitwith no worker serving says so (launch one) instead of waiting out its timeout. The heartbeat is a thread, so a long job keeps beating, and a beat a reader blocks (Windows) is skipped, not raised. mayatk'sLightLayerBakerworker runs on it.- Test(s): new
test_job_queue.py(12).
- Test(s): new
- 2026-10-17 --
MathUtils.connected_components(count, groups): the components of vertices joined by rows of any width (edges, triangles), numbered by their lowest vertex (math_utils/_clustering.py,_math_utils.py;geo_utils/uv/pack.pyUvPack._separate_islandsuses it, as does blendertk's articulated rig). The vectorized min-label hookingUvPackkept to itself, now one primitive.- Test(s):
test_math.py+2 (shells numbered by their lowest vertex; pairs and triangles against a union-find on a shuffled strip); mutants: no join 2/2 FAIL, rows cut to two members 1/2 FAIL.test_uv_packpasses;surface_snapshot.json+1 entry.
- Test(s):
- 2026-10-17 -- A GLB built in a host with no cv2 still ships its lightmaps and reflection probe (
file_utils/mesh_convert/_lightmaps.pyapply_glb_lightmaps(hdr_codec=),embed_glb_lightmap_probe(hdr_codec=);_fbx2gltf.pyfbx_to_glb(hdr_codec=);glb/pipeline.pyGlbPipeline.build(hdr_codec=);net_utils/preview/bridge.pynewPreviewBridge.hdr_codec(), forwarded byPreviewDeliverer). The build read its float images through cv2, and Blender's Python has none. Measured in factory Blender 5.1 and the user's 5.1 profile: a baked room's Scene Exporter GLB and its WebXR push both shipped unlit and without the probe, every map warned "not encoded". The host now hands the pipeline one codec -- file I/O only, an object with ImgUtils' two HDR encoders; the pixel math staysImgUtils.encode_hdr_pixels_for_web.Nonekeeps cv2.- Test(s):
test_mesh_convert+2 (a host codec binds maps and probe with cv2 blocked; without a codec the warning names cv2), the codec reaches the applier, the pipeline and the deliverer (test_glb_pipeline,test_preview_server).
- Test(s):
- 2026-10-17 -- The web lightmap encode divides by the map's peak, capped at four times its 99.5th percentile, and clips past it without changing hue (
img_utils/_color_space.py,_img_utils.py: newImgUtils.encode_hdr_pixels_for_web(rgb, percentile=None),ImgUtils.HDR_WEB_HEADROOM;encode_hdr_for_webencodes through it). Dividing by the 99.5th percentile clipped lit surfaces: measured on production rooms, the backsplash and floor pools went flat grey, 58-68 screen codes off (the clipped 0.5% was never just the light sources). With the capped peak every highlight lands within 2 codes, hue shift is zero, and the PNGs come out 1-11% smaller; the cost is coarser dithered grain in the mid-tones (about 2.5 screen codes per lightmap code, averaging to 1-1.5), bounded by the cap -- a 1000x firefly cannot stretch it further. The format is unchanged (sRGB times the manifest'sintensity), so every viewer and reader keeps working. Weighed against RGBM, RGBE, LogLuv, gain-in-alpha, a gain map, a stronger curve and 16-bit/half PNG: the shared-exponent and multiplier encodings break under mip filtering and KTX2 UASTC; UASTC HDR waits on three.js r170+. The pixel form is cv2-free, so Blender's encode calls it (blendertk). An explicitpercentilekeeps the old divide.- Test(s):
test_mesh_convert.pyTestGlbLightmapsnewtest_encode_keeps_a_lit_pool_brighter_than_the_percentile,test_encode_holds_a_firefly_at_the_headroom,test_encode_clips_a_texel_above_the_cap_without_changing_its_hue,test_encode_pixels_is_the_file_encode_without_the_file(red first, 4/4); the golden pins (constant 0.5, lone 2.0 texel) unchanged.test_mesh_convert+test_img839 passed, 1 skipped.
- Test(s):
- 2026-10-17 -- Look match: lighting a scene toward a reference image, as layers of light, held to the WebXR viewer (
core_utils/engines/lighting/newLightLayers,LightMix,LightPredictor,ViewerTransform,LookTarget,LookRegion,LookMeasures,LookScore,LookFit,LookSheet,LookMatch;net_utils/preview/look_capture.pynewLookCapture;img_utilsnewImgUtils.tonemap,linear_to_oklab,resample_area,warp_quad). The engine behind a relight that had been a pile of session scripts. Each light is baked alone and captured once, linear, in the real viewer; lightmaps and the probe are linear in each light, so a mix's frame is the weighted sum of its layers, tinted by colour temperature at unit luminance (level and hue stay apart), through the viewer's tone map -- predicted in milliseconds instead of a bake. ALookTargetnames what matters in the reference as regions (areas, lines, quads rectified across a perspective, each with its own outline on either image and cut-outs),LookMeasuresreads them (level, peak, footprint, chroma, hue, warmth; profiles; falloff along and down a quad),LookScorecompares,LookFitsolves the free weights (bounded log-weight Levenberg-Marquardt), andLookSheetdraws the review sheet an agent or a person reads.LookMatchties a session file together, andto_rigwrites a fitted mix as a mayatk light-rig payload (bake gain, ACEScg colours, emission as material rows). CLI:python -m pythontk.core_utils.engines.lighting score|sheet|fit|rig|layers session.json.LookCaptureis the ground truth the prediction is held to: the viewer headless (Playwright, Edge, SwiftShader -- the same frame on every machine), a mix's summed bake swapped into a template (viewer.lighting.relight, new in the kernel API, additive) and shot lit or linear;servekeeps a template loaded so a frame costs seconds, not the minutes a load takes (stopends it, closing its browser). An exact frame can swap any bake file for one on disk and place the probe elsewhere (a what-if without a layer), and shoot as a layer capture sees it (as_layer: the desk metals' reflections out,!patternkeeps a lens) to hold the prediction to it -- measured on f3, display p99 2 codes, total light within 0.7%;capture_baketurns a bake folder (mayatk'sLightLayerBaker) into a layer.LookMatch.score/sheetjudge any display frame (--frame, an exact capture) by the prediction's measures;fit --max-iternow reaches the fit (it was parsed and dropped). A blacked material's reflection really goes: three r163+ reads a scene-lit material's level offscene.environmentIntensity, so the capture gives it the scene's own environment at level 0 -- and a material shared by several meshes gets its own level back (it was restored to 0). Validated on a production room: the engine reproduces the prototype's frames (mean |d| 0.18/255) and its tint matches a real export within 1.4-4.4%.- Test(s): new
test_light_layers.py,test_light_predictor.py,test_look_target.py,test_look_measures.py,test_look_fit.py,test_look_sheet.py,test_look_match.py,test_look_capture.py(98 passed, 3 live skipped; each later addition red first; the live capture pair runs whenLOOK_CAPTURE_TEMPLATE/LOOK_CAPTURE_CAMERAname a template, measured 235 s);test_img.pynewTonemapTest,LinearToOklabTest,ResampleAreaTest,WarpQuadTest(10).
- Test(s): new
- 2026-10-17 -- Reflection-probe placement lives here, shared by both DCC bakers (
geo_utils/probe_placement.pynewProbePlacement,ProbeRays,ProbeSite), plusMeshConvert.embed_glb_lightmap_probe(glb, probe, search_dirs, hdr_codec=None)for a native export's GLB (_embed_lightmap_probe(hdr_codec=)) andMathUtils.fit_similarity(source, target)(Umeyama, proper rotation only, None when the pairs cannot fix a transform). The placement is mayatk's, host-free: a DCC supplies rays, bounds, lights, unit and up axis, and the centimetre literals now follow the host's unit. Embedding is idempotent and findslightmap_webwhere the viewer reads it.- Test(s):
test_probe_placement.pynew (9),test_mesh_convert+3,test_math+3; snapshot entries added.
- Test(s):
- 2026-10-17 --
ImgUtils.stitch_seams(..., hold=None)andImgUtils.assemble_atlas(..., canvas=None): writing back into an atlas already shipped (img_utils/_atlas.py,_img_utils.py).holdis an(H, W)mask of texels that never change: a pair's free side moves the whole way to meet a held one, a partly held side moves its free taps only, a pair held on both sides is left alone, and an empty hold is the two-sided solve bit for bit.canvasplaces the images onto a copy of an existing atlas instead of a background fill, so every texel no rect covers keeps its value exactly. mayatk's Re-bake Changed writes a re-baked cell back into its atlas with both.- Test(s):
test_img.pyStitchSeamsTest+5,AtlasAssembleTest+3 (red first; hold ignored 4/4 FAIL, canvas ignored 3/3 FAIL).test_img+test_bake_baseline414 passed.
- Test(s):
- 2026-10-17 -- New private scene record
SceneRecords.LIGHT_RIGS(core_utils/engines/scene_export/scene_records.py): the light rigs applied in a scene, by rig group name -- the rig, its fingerprint when it went in, the plugs it overrides and the carried sections, and the rig the last swap replaced. Written by mayatk's Light Rigs (mtk.LightRig). It keeps the state off the rig group: a node attribute rides every FBX as a user property. Unioned by group across a scene crossing.- Test(s): the registry and contract suites in
test_scene_records.pycover the declaration; the behaviour is tested by its producer (mayatktest_light_rig.py).sync_scene_records.pyregenerated the owner table (22 records).
- Test(s): the registry and contract suites in
- 2026-10-17 -- An FBX depends on what a reader of it binds: the scene's own record wins, and a referenced module's names only the nodes the file carries (
file_utils/mesh_convert/fbx_media.pynewFbxMedia.reader_records,FbxMedia.NODE_ENTRIES, read bydependencies/embed_dependencies/extract_dependencies;export_verify.pycheck_fbx_dependencies;fbx_file.pyFbxFile.user_properties(named=)). An assembly export carries each referenced module'sNS:data_exportas that module was last exported alone, and the embed and the verifier walked every carrier's maps: an export that left a module's objects out embedded their lightmaps when it found them, or failedfbx_dependencieswhen it did not (measured: SOLDERING_ASSEMBLY_MOODY referenced as a module, 8 maps named for a file carrying none of its objects). Each record is now read the way unitytkCarrierImport.Collectand the GLB applier read it: the scene's own copy wins; a module's copy is read only where the scene's has none, cut down to the entries whose node the file carries (exact name, else namespace-stripped leaf); a module copy with no such entry names nothing, its probe included. No record in the file changes.- Test(s):
test_fbx_media.pynewtest_a_module_record_the_scenes_own_shadows_names_nothing,test_a_module_record_names_only_the_nodes_the_file_carries;test_export_verify.pynewtest_a_modules_files_count_only_where_a_reader_binds_them(red first; precedence off fails 3/3, narrowing off 2/3). 165 + 519 passed (1 skipped).
- Test(s):
- 2026-10-17 --
FbxFile.take_keys(properties=(), user=False, kind="Model"): every key of the chosen channels, take by take, as[ticks, value, interpolation](file_utils/mesh_convert/fbx_file.py, newFbxFile.KEY_INTERPOLATIONS). It reads what an importer that sees only transforms leaves behind -- a node'sVisibility, a DCC's custom attributes (user properties) -- straight from the file, on the base layer, each key's interpolation in the transfer vocabulary (step / linear / smooth).take_curvesstays the census; this returns the keys. blendertk'sFbxUtils.import_fbxlands them.- Test(s):
test_fbx_media.pynewTakeKeysTestCase(2;build_curve_fbx(user=)); red: method missing 2/2 FAIL, modes mutated 1/2.
- Test(s):
- 2026-10-17 -- The WebXR shadow rig lays a Blender-made shadow plane flat and reads its contact's frame (
net_utils/preview/features/shadow_rig/shadow_rig.jsquadExtent/place/feedSource, newZ_UP_PLANE/Z_UP_FRAME). Blender's FBX exporter turns only the root nodes upright, never the mesh data, so a blendertk quad reaches the GLB in its local X/Y and its contact keeps Blender's axes -- at the root and under one alike (measured on blendertk WebXR pushes). The page's yaw-only placement stood every following Blender plane on edge, and the horizon frame took the contact's +Y as up (it lay along -Z). The page now tells a Z-up quad off its mesh (unitytk'sIsZUprule), turns it onto the ground, reads its canvas mirrored across the bearing (no Unity import mirror here), and reads a Z-up contact's frame through the +90 deg X turn back. Placement matches the Blender drivers' exported corners within 1.4e-6 on four real pushes, a lopsided canvas included; a Maya push is unchanged (5.8e-9).- Test(s):
test_shadow_web.pynewtest_a_z_up_quad_lies_flat_where_the_blender_drivers_put_it,test_a_z_up_contact_reads_its_frame_in_the_files_axes(red first; an in-flight mutation of each of the three parts fails its test);test_shadow_web38 OK.
- Test(s):
- 2026-10-17 -- The lightmap bake baseline: what a re-bake of the changed objects must redo, decided without a DCC (
core_utils/engines/scene_export/bake_baseline.pynewBakeBaseline,StaleReport;scene_records.pynew private recordSceneRecords.LIGHTMAP_BAKE_STATE). Per object, the record keeps the state its lightmap was baked against -- transform, world bounds, an exact topology hash with point moments, the lightmap-UV fingerprint, the material group and the atlas cell -- plus the lighting each bake saw.BakeBaseline.assessnames what is stale and why (moved, deformed, remodelled, UVs re-laid, material, shown or hidden, map missing, lighting, duplicate) and each neighbour a change reaches through its old or new box (one ring, clamped to 1-25% of the room; isolated objects reach none);advancerolls the record forward at each bake, keeping what that bake would otherwise forget. Moments with size-relative tolerances, not a point hash, so a.masave and reopen (~7 significant digits of float32) reads as unchanged. One rule for both DCC bakers; private, the scene's own, never shipped.- Test(s): new
test_bake_baseline.py(39; red first; 22 in-process mutations each caught);test_scene_records.pynewTestLightmapBakeState(2).
- Test(s): new
- 2026-10-17 --
audio_manifestis a derived record, so every hand-off refreshes it (core_utils/engines/scene_export/scene_records.pySceneRecords.AUDIOkind=Kind.DERIVED). Its events are read off the keyed tracks and sound strips an artist moves between one push and the next, as the visibility curves are. Declared authored, it was republished by nothing at authoring time (mayatk's before-export hook stands down inside the export bracket, which became hand-off only on 2026-09-18 -- a regression, not a decision), and a hand-off context refreshes only derived records: measured on mayatk's Unity bridge, a scene with a keyed track shipped noaudio_manifest, and after a publish a track added since was missing.- Test(s):
test_scene_records.pynewtest_a_handoff_ships_the_audio_its_scene_plays_now(red first);test_fbx_media.pynewtest_audio_events_reads_the_carrier_the_readers_read(two carriers + module-only; red first).test_scene_records123 passed.
- Test(s):
- 2026-10-17 --
ArticulationPlan: the plan anArticulatedRigbuilds from, host-free (geo_utils/articulation/plan.py, rootDEFAULT_INCLUDE). The cmds-free half of mayatk's ArticulatedRig moved here so blendertk's coming twin shares it: the plan's check and parent-first ordering, the joints' rest frames and thearticulationrecord's joints, the end control's placement, offset, pivot and turn, the post-rig edits (insert, remove and edit a joint; limits; weights; retype), the pivots put out for adjusting and read back, and the panel's limits and give text. Values in, values out: matrices are 16-float row-vector lists, the plan's ownend.framelayout. The joint orient is read exactly as Maya'sMTransformationMatrixreads it (measured on 20,000 rotations, worst 5.7e-14), so mayatk's rig builds the same joints.- Test(s): new
test_articulation_plan.py(36; seven in-process mutations each fail their test);test_articulation31 OK;surface_snapshot.json+ArticulationPlan.
- Test(s): new
- 2026-10-17 --
ImgUtils.atlas_seam_pairs(edges, rects, size, tolerance=1e-4, coplanar=0.99): the atlas positions where two members read one 3D point of an edge they share (img_utils/_atlas.py,_img_utils.py). The inputImgUtils.stitch_seamsneeds, moved out of mayatk'sLightmapBaker._seam_pairsso blendertk's twin shares it: per member, its open edges as (E, 16) rows (world ends, lightmap UVs, face normal, inward vector) and its published rect. Edges are matched when collinear, overlapping, coplanar within coplanar and on opposite sides of the edge, and sampled twice per atlas texel along the overlap; a corner, a fold, a strip standing in front and a decal along a border are not matched.- Test(s):
test_img.pynewAtlasSeamPairsTest(9; the fold and decal tests each prove their gate by opening it).
- Test(s):
- 2026-10-17 --
AudioUtils.scope_eventsandFbxMedia.audio_events: the audio manifest's take-scoping rule and its event names, shared by both DCCs (audio_utils/_audio_utils.py,file_utils/mesh_convert/fbx_media.py). mayatk kept the rule that puts eachaudio_manifestevent in the take that plays it private onAudioClips, and read a written FBX's event names inline; blendertk's new audio producer needs both, so they live here.scope_events(events, takes, origin)returns the record's{clip, frame, name}events, each rebased to its take's start, plusname@framefor every event outside every take; a take missing its name or range scopes nothing, and with no usable take events ship unscoped, counted from origin.audio_events(path)returns the track names the events on the ONE carrier its readers take play -- the scene's own, else the first that carries the record (the GLB applier's precedence, unitytk'sCarrierImport.Collect), each name once -- the names a host resolves to files forembed_dependencies(files=).- Test(s):
test_audio_utils.pynewTestScopeEvents(6, red first);test_fbx_media.pynewtest_audio_events_names_the_tracks_the_manifest_plays(red first).test_audio_utils46 passed,test_fbx_media58 passed.
- Test(s):
- 2026-10-16 --
UvTransfer.transfer_materialscan rewrite existing maps in place (geo_utils/uv/transfer.py: a job's optional"paths": {channel: [files]};save_mapwrites the format its extension names; newWRITABLE_EXTENSIONS,in_place_paths, andlayout_jobs(per_material=)). Texture transfer's Overwrite target (mayatk / blendertkTextureTransfer.transfer(assign_from="in_place")) re-bakes each target material's OWN maps where they are, so its file nodes and file names stay. A listed channel is written to every named file and nothing for it lands inoutput_dir; no fresh output is named onto one, and one file named by two channels or outputs raisesValueErrorbefore anything is written.in_place_pathsis the hosts' shared rule for which maps may be rewritten: never one a source reads, a file under two channels (a packed map), an unwritable format or file, a host-locked file, or one a material outside the re-used set reads.layout_jobs(per_material=True)keeps each material its own output (its own texture set), while one material under two UV set names still merges.save_map(now a classmethod; same call) keeps 16-bit grey in PNG and TIFF, drops the alpha for JPEG / BMP, writes JPEG at quality 95, and refuses an extension outsideWRITABLE_EXTENSIONS(EXR,.tx) withValueError; PIL used to fail on a 16-bit or RGBA map saved as JPEG.- Test(s):
test_uv_transfer.pynewTestSaveMap(4),TestInPlacePaths(4), 4 inTestTransferMaterialsOutputsand 2 inTestLayoutJobs(red first);test_uv_transfer118 passed.
- Test(s):
- 2026-10-16 --
AppLauncher.rungives its child an empty stdin (core_utils/app_launcher/_app_launcher.py:stdin=DEVNULLon the captured andoutput_filepaths, asspawnand the streaming path already had). A captured child inherited this process's stdin, and a GUI process whose console went keeps the dead console's handle value:DuplicateHandlefailed before the child started (WinError 6, or 50 once the value was reused). Measured: extapps' desktop-shortcut probe from a hub Explorer opened refused every app with "[WinError 50] The request is not supported". A captured child cannot prompt anyone, so nothing it could read is lost.- Test(s):
test_app_launcher.pynewtest_run_starts_when_this_process_has_no_usable_stdin(red first: WinError 6 on both paths).
- Test(s):
- 2026-10-16 -- A lightmapped GLB ships no
KHR_lights_punctuallights (file_utils/mesh_convert/_lightmaps.pynewMeshConvert.strip_glb_baked_lights; run unconditionally in thefbx_to_glbchain after the lightmap pass). A moody room exported under a saved FBX preset (lights on) carried its twelve bake spots into the GLB, and three.js lit every baked surface a second time with no shadow: the floor under the table read 3x its baked value in WebXR. Every scene light is in the lightmaps already, so the lights go whenever alightmap_webmanifest is present; a GLB without one keeps them.- Test(s):
test_mesh_convert.pynewTestBakedLightsLeaveTheGlb(2; the lightmapped case red first);test_mesh_convert455 passed, 1 skipped.
- Test(s):
- 2026-10-15 -- The base-colour section carries a material's opacity factor (
file_utils/mesh_convert/_materials.pyMeshConvert.set_glb_base_coloracceptsalpha->baseColorFactor[3], clamped; absent, the converter's alpha stands). A Maya shadow plane held at a static 0.836 fade reached WebXR at alpha 1.0, solid black: FBX carries no scalar opacity for a modern shader.- Test(s):
test_mesh_convert.pynewTestSetGlbBaseColorAlpha(4; 3 red first).
- Test(s):
- 2026-10-15 -- A share's link check takes a guest's route and a browser's trust (
net_utils/preview/_sharing.pyPreviewServer.ping_shareresult gainsroute/verified;_net_utils.pynewNetUtils.public_addresses,NetUtils.tls_context;remote_file.pyRemoteFile.openverifies with it). Reported: Test Link said a Tailscale link did not answer (certificate has expired) while guests opened it. Two faults. The check verified withssl.create_default_context(), which loads the Windows stores as they stand -- one kept an expired cross-signed ISRG Root X2 (2025-09-15), and OpenSSL 1.1.1 picked it, failing every Let's Encrypt site from Python;tls_contextleaves expired anchors out (and addscertifiwhen installed), and a certificate this machine still cannot verify is asked again unverified, passing withverified: False. And it resolved the name on this machine, where MagicDNS answers ats.netname with the tailnet address -- a pass proved nothing about the public ingress. It now resolves through public DNS (public_addresses, the A recordsresolves_publiclyalready asked for) and connects there, so a name public DNS lacks fails it: measured live, a new Funnel name reached public DNS 5.5 minutes after the share began on one run and was not published within 8 or 25 on two others (ts.net's authoritative servers said NXDOMAIN), and left within a minute of it ending (Tailscale 1.102.2); once there, the check passed verified (670 ms).- Test(s):
test_net_utils.pynewTestPublicAddresses(3, a CNAME chain),TestTlsContext(3: expired vs valid anchors, built once, no expired anchor on Windows);test_preview_server.pynewtest_a_ping_takes_the_route_a_guest_takes,test_a_name_public_dns_lacks_is_unreachable_and_never_asked,test_a_certificate_this_machine_cannot_verify_still_reaches,test_a_tailnet_only_link_is_asked_through_this_machine.
- Test(s):
- 2026-10-15 -- The scene sidecar's
unlitsection: a GLB material the DCC authored unlit shipsKHR_materials_unlit(file_utils/mesh_convert/_materials.pynewMeshConvert.set_glb_unlit,UNLIT_EXTENSION;_sidecar.pySIDECAR_APPLIERSrow). FBX2glTF makes every material lit PBR; a projected shadow plane then took the viewer's environment specular and read brighter than the floor under it. Metallic 0 / roughness 1 ride along for a viewer without the extension. Read by mayatkSceneState._read_unlit(shadow-plane materials,surfaceShader).- Test(s):
test_mesh_convert.pynewTestSetGlbUnlit(3: marks only the named material and declares the extension, nothing named leaves the file alone, registered as a section);test_mesh_convert449 passed, 1 skipped.
- Test(s):
- 2026-10-15 -- Several texture sets' UV islands planned and packed into one atlas (
geo_utils/uv/combine.pynewUvCombine,CombineShell/CombineGroup/CombinePlan/CombineLayout;geo_utils/uv/pack.pynewUvPack.solve_similarity/apply_similarity;scene_export/scene_records.pynew PRIVATESceneRecords.TEXTURE_COMBINE). The planning half of mayatk's Texture Combine, arrays in -> arrays out.UvCombine.clustersgroups one set's islands that overlap (stacked copies, mirrored halves, transitively; never across sets) so each group moves as one rigid piece and the atlas never pays twice for shared texels;plansizes the groups --sizing="density"(equal world texel density) or"proportional"(each set keeps its own island proportions, the sets normalized by average density or by source resolution) -- times per-island overrides, and estimates each set's share and kept resolution without packing;packpacks one stand-in per group (the island itself, the largest member of a contained stack, else the hull; a stand-in the engine splits re-packs as its hull) and applies each group's solved move to every member. The rigid-move solve moved down from mayatk's_uv_pack(same semantics) so both share it.texture_combineis the record the combine keeps (original UVs, face materials, source maps, settings; never exported).- Test(s):
test_uv_combine.pynew (14: clusters, plan modes, overrides, stack footprint, pack keeps stacks stacked / proportions / hull clusters);test_uv_pack.pynewTestSimilarity(4).
- Test(s):
- 2026-10-14 -- Two shots can trade timeline places, and one can take a copy of another's animation (
core_utils/engines/shots/shot_sequencer.pynewShotSequencer.swap_shots(retain=),SWAP_FIELDS,copy_shot_content;shot_plan.pynewShotPlanner.plan_order). Asked for in the Shots window ("swap shot positions with a 'Swap With x' button ... which shot properties to retain").swap_shots(a, b, retain=())exchanges two places in the order -- each shot takes its keys, members and duration, the others keep their positions, laid out asmove_shot_to_positionlays one out -- and a field named inretain(name,description,locked,metadata) stays in the place: the two records exchange it, so the animation changes places under it. Retained metadata (the manifest step a place pairs with) keeps the place's unanimated members with it; animated ones travel with their keys, and the system's member tags follow their members.copy_shot_content(source, dest)replaces a shot's animation: its own keys are cut (delete_shot's cut, now the shared_cut_owned_content: the shots beside it hold their poses), it takes the source's duration (the shots after it ripple), and the source's animator keys are copied in at the same offsets; the destination keeps its record and its unanimated members.ShotPlanner.plan_order(store, order, gap)lays the shots out in any id order;plan_reorderbuilds on it. Verified on a production assembly (fresh mayapy, every curve sampled every frame of all 28 shots): the swapped and copied shots play their sources' keys exactly, every other shot plays as before.- Test(s):
test_shot_sequencer.pynewtest_swap_*(5),TestSwapPlaceMembers(3, a member keyed only with flat holds travelling with its key),TestCopyShotContent(3), the missing-id pin +4 calls;test_shots_core.pynewTestPlanOrder(4 + the inherited reorder pins).
- Test(s):
- 2026-10-14 -- A Scene Export that ships a GLB ends with an
Open <name> in WebXRlink (core_utils/engines/scene_export/scene_exporter.pynewSceneExporterBase._glb_created;net_utils/preview/bridge.pynewPreviewBridge.preview_file). The host reports each GLB that lands through_glb_created, which logsGLB createdas before and keeps the path._close_file_loggingthen writes anaction://previewlink after the run-log link, for the panel only: the file log is closed by then, and a link there would be dead text. The link shows even when the run log could not open.preview_file(path, logger)ispublish_filefor a click: the deliverer decides about the tab ("auto": a new one only when no page is watching), the log line names the page URL, and a stale link warns instead of raising. Tests:test_scene_exporter.py+2,test_preview_server.py+2. - 2026-10-14 -- An FBX dependency the host's folders miss is searched for under its texture root, and the warning names that root alone (
file_utils/mesh_convert/fbx_media.pyFbxMedia.embed_dependencies(walk_root=)). Reported on a production module: the warning listed five folders, a temp stage among them, and buried the one file name that mattered. A name missing from search_dirs and the FBX's folder is now looked for anywhere under walk_root (FileDependencies.find_files: a unique hit embeds; same-named copies are never guessed between). The warning readsnot found in <root> or any of its subfolders, so the FBX ships without: <names>, and the folders tried go to DEBUG, which the run log keeps. Tests:test_fbx_media.py+1. - 2026-10-14 -- The hand-off contract rebuilds the whole approved scene, lighting included: an
assemblylist in both deliverables, the FBX's lighting recipe in the FBX's own addresses (core_utils/engines/scene_export/scene_records.pynewRecordSpec.assembly,SceneRecords.HANDOFF_ASSEMBLY,HANDOFFrecord v2,HANDOFF_INSTRUCTIONSrewritten;file_utils/mesh_convert/_sidecar.pynewMeshConvert.RENDERING_CARRIERS,LIGHTING_ASSEMBLY,HANDOFF_ASSEMBLY,rendering_policy(carrier=)). Reported: a coworker handed a delivered scene to build dropped its lighting environment, among other settings. Read off the delivered assembly FBX, the FBX'srenderingwas the GLB's text word for word, so its environment pointed atextras.lightmap_web.probe-- an address no FBX has -- while the probe rodedata_export.lightmap_metadata, embedded; it now names that record, its OpenEXR, its scene units andunit_scale, every number unchanged. The probe's level is stated for both lightmap conventions on both carriers (probeIntensity, 1/pi, is three.js'; a renderer compositing albedo x texel -- Unity's Standard / URP Lit, as unitytk does -- plays it at 1). The file also carried a referenced module'sdata_export1with its own olderhandoff(maps "NOT embedded",intensity"the multiplier restoring the bake's range"), a stalelightmap_metadata(six objects since removed,_LightMapwhere the embedded copy is_Lightmap, no probe), and 16<object>__opacity/__highlightproxies plus avisibility_trackshighlight ramp no description named. The text now states the readers' own rule -- per record the node named exactlydata_exportwins, another carrier's copy is read only where it has none, and ahandoffdescribes the file only on that node (by node, never by version, so a module re-exported later cannot outrank the file's own) -- and that names join without case;assemblylists the approved scene part by part (the content, each record present from its ownRecordSpec.assembly, then the environment, key light, lightmapped-material and renderer rows), each with the data it is built from, andvisibility_tracks' description namesvisibility,opacityandhighlightwith its colour stops. The GLB envelope gains the sameassembly(its sections stated as already applied). Declarative throughout: what the scene is, never an action outside the file.mayatk/docs/data_nodes.mdregenerated (sync_scene_records.py).- Test(s):
test_scene_records.pyTestHandoff+5 (test_the_policy_points_at_this_carriers_probe,test_assembly_rebuilds_every_record_present_and_the_lighting,test_every_shipping_record_declares_its_assembly,test_the_render_effects_are_described_channel_by_channel,test_the_contract_says_which_carrier_wins_and_how_names_join),test_mesh_convert.pyTestFbxHandoff+3 (test_publishes_the_glb_policy_in_its_own_addressesreplaces the same-policy pin,test_an_unknown_rendering_carrier_is_refused,test_the_probe_level_is_stated_for_both_lightmap_conventions),TestSceneSidecar.test_handoff_assembly_lists_the_approved_scene_lighting_included-- all red against HEAD (the carrier-refusal one by its missing parameter).
- Test(s):
- 2026-10-14 -- The Scene Exporter keeps ONE log per scene in the per-user cache, never a
.logbeside each deliverable (newRunLog,core_utils/logging_mixin/run_log.py;core_utils/engines/scene_export/scene_exporter.pynewSceneExporterBase.log_file_path,LOG_FOLDER,_close_file_logging,_remove_legacy_logs,setup_file_logging(header=),close_file_handlers(footer=);generate_log_file_pathdeprecated, removed in 0.17.0). Each export wrote<export name>.loginto the export folder, so a timestamped or versioned Output Filename left a new log in the delivery folder on every run, and nothing ever removed one. The log is now keyed by the SCENE --<scene>_<hash>.loginUserConfig.cache_dir("logs", "scene_exporter"), the hash keeping two same-named scenes apart -- each run appended between a header (time, scene, output) and a footer (succeeded / failed / cancelled, with the duration) written past every log level, the file trimmed to its newest runs past 1 MB, and logs not written for 30 days swept. The first export into a folder deletes the per-export logs earlier versions left there: only a.lognamed for a deliverable beside it and holding the exporter's own text (or nothing). The run's last panel line links the log, and a log that cannot open is warned about, never the reason an export stops. The log records down to DEBUG whatever the panel shows (RunLog.attach(capture=)lowers the logger for the run and holds every other unpinned handler at the level it showed, by LoggerExt's_pinned_level), so the panels need no level option.RunLogis the general primitive (file_name,attach/detach,trim,sweep), one classmethod per step.- Test(s):
test_scene_exporter.pyTestRunLog(8; red: two exports left two logs beside the deliverables; a GLB-only run's header named its temp FBX),test_scene_exporter.pytest_the_log_keeps_debug_detail_the_panel_never_shows;test_logging_mixin.pyRunLogTest(7; a handler pinned below the logger kept its own level; red; a header that cannot encode left the file open while its exception was held);surface_snapshot.json+RunLog.
- Test(s):