github m3trik/pythontk v0.16.0
pythontk v0.16.0

latest release: v0.16.1
3 hours ago
  • 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.py new private ExportProfile._retired_row_notes; scene_exporter.py new SceneExporterBase._note_retired_preset_rows; file_utils/_file_utils.py FileUtils.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_lock named a read-only file "in use by another process".
    • Test(s): test_export_profile.py TestRetiredPresetRows (7) and test_scene_exporter.py +1 (red: AttributeError); test_file.py read-only description (red first).
  • 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.py new FileUtils.describe_unwritable; core_utils/deprecation.py new Deprecation.class_attribute; scene_export/scene_exporter.py new private _note_retired_panel_state / _saved_widget_values; export_profile.py new 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_unwritable creates and removes a probe file in the nearest existing folder, since os.access reports an ACL-denied folder writable. BAKED_REFLECTIONS_OPTIONS / BAKED_REFLECTION_LEVELS warn when read (removed in 0.17.0) through the new general Deprecation.class_attribute descriptor.
    • Test(s): pythontk retired-panel-state checks, describe_unwritable (a real icacls /deny folder + a mocked refusal), class_attribute (class and instance reads, dataclass caveat) -- each red first; 383 passed with the surface snapshot (adds describe_unwritable).
  • 2026-10-19 -- A saved retired RegEx field really migrates (core_utils/engines/scene_export/scene_exporter.py new SceneExporterBase._migrate_legacy_regex). It folds a saved txt002/<signal> into the Output Filename's saved pattern through ExportProfile.fold_legacy_naming before 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.
  • 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.py new ExportProfile.VERIFYING_CHECKS / ALWAYS_CHECKS, run_config; GLB_LIGHTING_ROWS now empty; glb_options / glb_defaults without the row; baked_reflections_default, ExportRun.baked_reflection_level and ExportRun(baked_reflections=) deprecated, removed in 0.17.0; docs/webxr_preview.md, net_utils/README.md). run_config arms check_mangled_names, check_duplicate_materials, check_sheared_local_transforms, check_floating_point_keys and check_untied_keyframes whenever 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_writable is 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_tasks drops an older panel's row with a note, and ExportRun(baked_reflections=) still publishes its level, warning, until 0.17.0. GLB_LIGHTING_ROWS stays, empty, for a published extapps that builds its Lighting section from it.
    • Test(s): test_export_profile.py TestRunConfig +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; TestBakedReflections rewritten for the retirement (4); TestGlbOnlyProducer without the row; test_preview_server.py test_a_push_publishes_the_lighting_recipe_whatever_a_retired_row_says. 66 passed; the preview test passed alone.
  • 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.py FbxMedia.drop_host_lighting_media, new private _remove_objects / _video_file; core_utils/engines/scene_export/scene_records.py LIGHTMAPS.assembly; _highlights.py embed_fbx_highlight_probes(logger=), _mesh_convert.py finish_fbx_deliverable). The FBX SDK writes a file's bytes once, in the first Video naming it, so dropping a StingrayPBS environment Video could strip the bytes of a user texture (or a probe) on the same file: a Video whose file a kept Video or a scene record names now stays, wired to nothing. The lightmap_metadata contract shipped in every deliverable describes probe.highlights (each map a light layer added to the probe, with receivers / 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.py test_a_file_another_video_or_a_record_names_keeps_its_bytes (red first); test_scene_records.py test_the_lightmap_record_describes_the_probes_highlight_copies; test_mesh_convert.py TestFinishFbxDeliverable.test_the_highlight_pass_says_its_outcome_where_the_host_logs (red first). 814 passed across the five suites.
  • 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.py LightRigPayload / LightRigLight summaries; pos (was "the file's units"), aim, scale, radius, existing help); the published JSON Schema follows.
    • Test(s): test_light_rig_payload.py +1 (red: pos's description).
  • 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.py BakeBaseline: @rule, lighting_state(rule=) / lighting_digest(rule=), assess(lighting=callable), new lighting_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. @settings keeps GI depth/bounces only.
    • Test(s): test_bake_baseline.py per-rule staleness, a rule-less record settles after one change, TestLightingRecord (red: 4 failed; 43 passed).
  • 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.py new private _fbx_static_scene, _fbx_highlight_copies; fbx_media.py FbxMedia.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.py test_no_names_drops_every_take; test_mesh_convert.py TestGlbHighlightProbes +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.
  • 2026-10-19 -- A hidden run honours a cancel (core_utils/app_launcher/_hidden.py _run_hidden; _app_launcher.py new _AppLauncherInternal._cancelled, shared by the streamed and hidden paths). AppLauncher.run(hidden=True) checks the active CancelScope while it waits: a cancel ends the hidden app's whole process tree at once and raises OperationCancelled, instead of waiting out the timeout. An app that already exited is never reported cancelled.
    • Test(s): test_app_launcher.py TestRunHidden.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.
  • 2026-10-19 -- StaleReport.summary says what a lighting change stales (core_utils/engines/scene_export/bake_baseline.py; new StaleReport.baked, filled by BakeBaseline.assess). "the whole room" only when every baked object is stale, else "N of M baked objects".
    • Test(s): test_bake_baseline.py test_the_lighting_clause_says_what_it_stales (red first).
  • 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.py new MeshConvert.finish_fbx_deliverable; _highlights.py new MeshConvert.embed_fbx_highlight_probes; fbx_media.py new FbxMedia.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 of FbxMedia.GEOMETRY_PIVOTS among its instances (the offsets, which the node keeps, do not count -- measured), the textures only HOST_LIGHTING_SLOTS sample 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_FIELDS names 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_ids and _replace_prop are shared helpers embed and drop_apparatus now use too; the MeshConvert module docstring lists the _highlights mixin.
    • Test(s): test_fbx_media.py new RecordTestCase (3), SplitPivotInstancesTestCase (3) and DropHostLightingTestCase (2), red first (no such method); test_mesh_convert.py new test_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) and TestFinishFbxDeliverable (2). On the production exports (fresh Maya, Unity preset): WALL_D and BASEBOARD_F split and WALL_A/WALL_B still 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.
  • 2026-10-19 -- A Scene Export that succeeds ends with an Open containing folder link (core_utils/engines/scene_export/scene_exporter.py SceneExporterBase._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_link already handle) and, when a GLB shipped, the Open <name> in WebXR link 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.py new test_a_successful_run_ends_with_a_link_to_its_folder (red first: no folder link); mayatk test_a_glb_export_ends_with_a_link_that_opens_it_in_webxr also dispatches the folder link through UiUtils.dispatch_log_link (fresh mayapy: passed).
  • 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.py new: MeshConvert.highlight_probe_layers, lights_in_metres (was the unreleased lightmap_web_lights); apply_glb_lightmaps embeds them as lightmap_web.probe.highlights and writes no lightmap_web.lights; img_utils new ImgUtils.encode_hdr_radiance_pixels; viewer kernel/probe.js builds the copies and puts each on the materials it names, kernel/baked_lights.js and viewer.lighting.setBakedLights removed, relight({highlights}); LookCapture.relight(lights=) draws them as the export does; the sidecar's RENDERING_POLICY states 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 camera user_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.smoothstep eases an array element-wise (a spot's cone over every surface sample at once); a number still returns a float. The pass reuses it and MathUtils.safe_normalize rather 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.py new TestGlbHighlightProbes (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_lights and the malformed-light regression reworked; test_img.py new test_radiance_pixels_keep_their_channels_and_range; test_math.py new test_smoothstep_eases_an_array_element_wise; test_preview_viewer_live.py new test_a_highlight_copy_is_the_probe_its_materials_reflect and test_a_highlight_copy_too_narrow_to_prefilter_is_reported (the first red first, on its fixture's too-narrow copy); test_preview_server.py test_a_baked_material_takes_the_environment_as_specular_only pins 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.py new: AppLauncher.run(hidden=), AppLauncher.can_hide; _processes.py AppLauncher._new_job / _end_job, now also spawn'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 runnable mayatk/test/rizom_headless_probe.py cases pass in 140 s with no RizomUV window on the user's desktop and its saved frame unchanged; a Toolbag 5 roundtrip bake through MarmosetEngine writes 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, as hide_window is; 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. Not SW_HIDE either: RizomUV never finishes its script under it. Nor for launch: 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.py new TestRunHidden (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) and TestRunStreaming (1, red first: the child outlived the error). Each guarantee is red against a plain Popen (7 failures, the timeout one at 120 s), without the handle list, and without poll ending the job. test_app_launcher + test_script_run 117 passed, 12 skipped.
  • 2026-10-19 -- Two preview log lines its caller already reports are debug now (net_utils/preview/bridge.py PreviewBridge.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." repeated PreviewBridge.sidecar_summary and was said for file pushes, which never carry a sidecar. The link is share()'s result and the sidecar outcome is the push result's sidecar_requested / sidecar.
    • Test(s): pinned from the consumer -- extapps test_webxr_preview.py test_every_launch_link_is_followed_by_a_copy_link asserts the pane never shows "Sharing the preview at"; test_preview_server.py + test_share_tunnel.py 326 passed.
  • 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 (then lightmap_web_lights) leaves that light out and the maps and lightmap_web manifest ship (the guard used to log "GLB lightmaps skipped" after the materials were already rebound).
    • MeshConvert.strip_glb_baked_lights finds a natively exported lightmap_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_slot shared with embed_glb_lightmap_probe.
    • ProbePlacement resolves 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); CombineInputs gains shell_points.
    • UvTransfer.save_map no longer passes Pillow's deprecated mode= (removed in Pillow 13).
    • ArticulationPlan.limits_text shows 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_layer handed LightLayers.add paths inside the folder it clears first: FileNotFoundError); LightLayers.add returns the layer's .npy and writes it atomically; an exclusion on one side of a region no longer cuts the other side's (shared, cached) painted mask; LookScore reads one-value statistics under numpy 2 and stays readable after its target's regions change (it scores a snapshot); a quad with a different grid per side is refused at construction (LookRegion.GRID); painted() assigns colours once per file; the sheet's good/bad colours follow LookScore.summary's rule (new MeasureResult.miss); JobQueue.wait no 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 ends serve, and ids sort in submission order within one clock tick; the capture service prints a non-ASCII path (PYTHONIOENCODING), and --from / --swap reach it as absolute paths; PresetStore.adopt retries after a copy that failed part way; the JSON writers go through FileUtils.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; restoreLighting waits 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 raising ValueError: string too long (the environment block is sized in UTF-16 units), and an empty env gets 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_layers samples 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_radius clamped), and a malformed light is left out (validated through lights_in_metres); LookCapture.manifest reads lightmap_web where the viewer does (MeshConvert._lightmap_web_manifest), so a natively exported template relights.
    • Internal: FbxFile._object_name shared by object_names and user_properties(named=True); test_shots_core.TestPlanOrder no longer re-runs TestPlanReorder's 8 tests; test temp dirs in test_app_launcher.py through TempArtifacts, and its shortcut test compares the link's target case-insensitively (the shell hands the drive letter back canonical, so a suite launched from o:\ 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.py TestIslandShells +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 across test_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.py TestRunHidden.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.
  • 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.py new LookSession (+ LookCaptureSettings, LookLoopSettings, LookWorker; LookMatch.open, LookLoop and LookCapture.open_session read it); lighting/light_layers.py new LightLayerScene (of, matches), LightLayerInfo.scene; lighting/light_rig_payload.py new LightRigFixture (MODES), LightRigPayload.fixtures, LIBRARY, library; look_match/loop.py new LookLoop.stale_layers, rebake, worker_stale, the match argument, stale counting an older scene save; look_match/measures.py new LookScore.summary (BRIGHTNESS, COLOUR_WORDS); look_match/sheet.py new LookSheet.region_row, difference (was _difference); look_match/target.py new RegionShape.drawn / from_points (rect, ellipse, polygon, line), LookRegion.drawable / spec_from_points, LookTarget.add / remove / rename; net_utils/preview/look_capture.py new LookCapture.spawn, capture_bake carries the scene record; file_utils/job_queue.py new JobQueue.lease / leased; core_utils/presets/store.py new PresetStore.adopt; core_utils/app_launcher AppLauncher.launch(log=); lighting/light_predictor.py LightPredictor.linear without a temporary per layer). The session file was a docstring three readers each parsed by hand; it is a typed contract, and a path spelled cache: 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, which WorkspaceTemplates' 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.summary reads each region as a person does (within tolerance, brightness on screen -- display values, not light -- and a colour word); LookCapture.spawn starts 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 of outline; 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 (its target declared nested= against an Optional annotation): fixed. LookSession.read of a file whose root is not a JSON object raised a bare TypeError (or a ValueError about dict sequences): now the session refusal.
    • Test(s): test_look_session.py new (5); test_light_rig_payload.py +4 (fixtures, the library and its migration, the scene record, every contract publishes its JSON Schema -- red: SchemaError on LightLayerInfo); 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; rebake from 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 (spawn reads 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.py a non-object file refused (red: the dict error); test_app_launcher.py +1 (launch(log=)); surface snapshot updated (LightLayerScene, LightRigFixture, LookSession).
  • 2026-10-18 -- An RPC service started on port 0 announces the port the OS picked (net_utils/rpc/plugin_core.py RpcPlugin.start / serve; the four staged _rpc_core.py copies re-synced). Both printed the port they were asked for, so a service asked for any free port said :0 and a launching process reading the line could not find it; serve also flushes the line, which a parent reading a pipe would otherwise wait on.
    • Test(s): test_plugin_core.py new test_serve_announces_the_port_it_bound (red: serving on http://127.0.0.1:0). 50 passed; sync_rpc_core.py --check and extapps/test/test_vendor_sync.py clean.
  • 2026-10-18 -- Enter VR on a hybrid-GPU laptop no longer leaves the headset black (viewer kernel/xr.js: renderer.xr.setSession wrapped; 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, then makeXRCompatible rejected (InvalidStateError) while Chromium relaunched its GPU process on the RTX. The context came back a second later, but three's setSession, 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); live test_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's this.setFoveation).
  • 2026-10-18 -- The WebXR page names a VR runtime switched under the browser, and the cure (net_utils/preview/server.py manifest xrRuntime is now the active runtime's name, false for none -- new PreviewServer._xr_runtime_name; viewer kernel/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, no devicechange to cure it, while a fresh Edge answered supported at once (measured). The manifest now names the runtime (its manifest's runtime.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.py test_the_manifest_says_whether_an_xr_runtime_is_installed -> test_the_manifest_names_the_active_xr_runtime (6 file shapes; red first); live test_a_runtime_switched_under_the_browser_names_its_cure (red first: the hint never named the switch).
  • 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.json it trades with a DCC are declared once (core_utils/engines/lighting/look_match/ -- _look_match, target, measures, fit, sheet moved from lighting/look_*.py, loop from net_utils/preview/look_loop.py; new lighting/light_rig_payload.py LightRigPayload / LightRigLight; lighting/light_layers.py new LayerKind, LightLayerInfo, LightLayerTarget, LightLayers.register_kind / kind; look_match/target.py new RegionShape, LookRegion.register_shape / outline / anchor / sample; core_utils/cli.py new CLI.Command / CLI.dispatch; img_utils new ImgUtils.luminance / kelvin_tint / sample_bilinear, core_utils/color.py Color.REC709_LUMINANCE; net_utils/rpc/plugin_core.py new RpcPlugin.serve, client.py new RpcClient.for_url; net_utils/preview/look_capture.py new LookCapture.client; removed, never released: LightPredictor.tint, LookCapture.request, the three LUMA constants, lighting/_look_match.main). Read against CODE_STANDARD: five look_* siblings were a namespace spelled into file names (now the look_match/ subpackage, the map_factory shape); the engine depended on the preview package only through LookLoop, which now takes an injected client and lives with the engine; the command line is python -m pythontk.core_utils.engines.lighting.look_match (and .look_match.loop). One owner per concept: Rec.709 luminance weights were spelled four times (three LUMA copies and Color.luminance), a light's unit-luminance kelvin colour lived on the predictor though mayatk's highlight lights needed it, and ImgUtils._bilinear was called from outside its class. Seams where a dispatcher grew a case per variant: a region's shape answered for its pixels in LookRegion but was re-derived for its outline and label in LookSheet and for its samples in LookScore (a RegionShape now owns all four, so a new shape is a subclass plus one registration -- and a quad's painted or object-ID exclusion, which raised KeyError: '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 and to_rig (LayerKind declares 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 became CLI.dispatch tables (LookMatch, LookCapture, LookLoop, mayatk's LightLayerBaker; the stdlib-only execution-monitor sidecar is exempt by design). The capture service hand-rolled an HTTP server and an if op == chain; it now registers its ops on net_utils.rpc and serves with the new RpcPlugin.serve on the calling thread, because the browser it drives answers only the thread that started it (capture's from key is source). RpcPlugin.stop called from an op deadlocked -- shutdown waits for the serve loop, which waits for the op -- and now hands the shutdown to another thread; the four staged _rpc_core.py copies are re-synced. Contracts: LookMatch.to_rig validates its output against LightRigPayload (both production MOODY rigs fit), LookCapture.capture_bake validates the layer.json it reads against LightLayerInfo (all 28 in the MOODY session fit).
    • Test(s): test_light_rig_payload.py new (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.json refused -- red with the checks off); test_cli.py new TestDispatch; test_img.py new TestKelvinTint, TestLuminance, TestSampleBilinear; test_plugin_core.py +2 (an op stops its own server -- red: hangs, rc 124; serve runs 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 --check and the surface snapshot clean.
  • 2026-10-18 -- A look sheet's region names stay clear of its frame captions (core_utils/engines/lighting/look_match/sheet.py new LookSheet._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.py new test_a_region_at_the_top_is_named_inside_it (red against the old clamp); test_look_sheet + test_look_match 27 passed.
  • 2026-10-18 -- A captured emission layer keeps the weight a mix scales (net_utils/preview/look_capture.py LookCapture.capture_bake). layer.json's target (mayatk's LightLayerBaker now writes it: the material's emission weight and the value it was baked at) reaches the store, so LookMatch.to_rig builds the layer instead of listing it unbuilt.
    • Test(s): test_look_capture.py new test_an_emission_bakes_target_reaches_the_store (red first); test_look_capture + test_look_match 40 passed, 4 skipped.
  • 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, and m3trik/scripts/sync_github_about.py holds 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.py the handoff sentence and LIGHTMAPS / AUDIO; geo_utils/articulation/record.py t and slide limits). A length stays in the file's own units with unit_scale beside it -- the hosts now honour that for every record (mayatk Units) -- and the handoff adds: an angle is in degrees unless its field says radians (a spot's half angle does), frames count at the record's fps (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_convert pass; sync_scene_records.py --check in sync.
  • 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.py new LookMatch.readout, owners, solve, exposed and CLI readout / owners / solve / tiles; look_sheet.py new LookSheet.pair, LookSheet.tiles; net_utils/preview/look_loop.py writes compare.png; net_utils/preview/look_capture.py new LookCapture.idle; viewer API viewer.setRendering(on) (kernel/ui.js setRendering, 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.png put ours beside the reference whole and close up; tiles lays candidate mixes, exact frames and each layer alone (auto-exposed by exposed) in one labelled grid; readout gives every region's display colour beside the reference's (exact frames judged as themselves); owners ranks the lights by how selectively they light a region (the one to reach for); solve finds 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's specular (LightMix.specular; a LookLoop role's "specular") is a layer's highlight level apart from its light -- Arnold's aiSpecular: it scales only the lights a deliverable ships for highlights (LightPredictor.lights), and LookMatch.to_rig writes it on the rig's lights, which LightRig sets as aiSpecular -- 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). LookCapture answers {"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_rig puts the store stamp's materials (an office's emission off) in the payload beside its existing, and a LookLoop round records its rig's base in the stamp, so applying the rig to another scene sets that state there. LightLayers.validate names 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). LightLayers re-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's emissive) 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 (CDP setWebLifecycleState) 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) and test_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.py new test_a_rig_carries_the_material_state_its_layers_were_baked_under, test_look_sheet.py +2 (pair, tiles), test_look_capture.py new test_a_service_sleeps_its_page_between_requests and test_the_files_own_bake_is_shot_as_it_ships, live test_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.py test_the_viewer_plays_the_clips_a_deliverable_carries anchors 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).
  • 2026-10-17 -- Look match reads regions as masks, not boxes, and fits colour as well as level (core_utils/engines/lighting/look_target.py new 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.py LookFit.run(kelvin=), Jacobian on threads; look_sheet.py outlines a mask by its edge; net_utils/preview/features/look_capture.js new ids, LookCapture.shoot_ids, request / CLI ids --target; LookMatch.fit(kelvin=), CLI fit --kelvin; LookLoop roles' fit_kelvin, the rig's fit.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 -- that paint_template starts 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.py new test_it_recovers_the_colour_temperatures_that_made_the_reference, test_look_sheet.py +1, test_look_capture.py new test_an_object_id_render_attaches_to_a_target (each red first).
  • 2026-10-17 -- Baked lights ship for the highlights a lightmap cannot hold (core_utils/engines/scene_export/scene_records.py SceneRecords.LIGHTMAPS gains lights; file_utils/mesh_convert/_lightmaps.py new MeshConvert.lightmap_web_lights, apply_glb_lightmaps writes lightmap_web.lights; viewer kernel/baked_lights.js new, 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 beside unit_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 of KHR_lights_punctual: a viewer that does not know they are baked would light the scene twice. They cast no shadow yet (ledgered). LookCapture lights a bake folder's lights into its layer (capture_bake), so a prediction holds the highlights too.
    • Test(s): test_mesh_convert.py new test_baked_lights_ship_in_metres_and_the_viewers_units (red first); test_look_capture.py new test_a_bake_folders_lights_light_their_highlights_in_the_layer (red first); the viewer's half measured live (the specular comparison above); check_js_types OK.
  • 2026-10-17 -- LookLoop: one round of look matching from a rig by role, in one call (net_utils/preview/look_loop.py, root DEFAULT_INCLUDE; CLI python -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); iterate bakes only the roles whose payload differs from the one their layer was baked from (a round that moves weights bakes nothing), captures them in the LookCapture service, 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 (a JobQueue folder) and the service are named by the session's loop section.
    • 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).
  • 2026-10-17 -- Dropping a rig's apparatus removes a bind pose it empties (file_utils/mesh_convert/fbx_media.py FbxMedia.drop_apparatus; _drop_pose_nodes reports 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 its Definitions count; a pose already empty in the source is left as written.
    • Test(s): test_fbx_media.py DropApparatusTestCase +1 (build_rigged_fbx(curve_pose=True); red first; mutant 1 FAIL). test_fbx_media 64 + test_export_profile 62 passed; blendertk test_scene_exporter 364/364.
  • 2026-10-17 -- A black emissive switches the glow off instead of being skipped (file_utils/mesh_convert/_materials.py MeshConvert.set_glb_emissive). FBX2glTF glows a mapped emissive at 1.0 whatever the DCC said, so a material authored dark -- the zero colour mayatk / blendertk SceneState now 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.py new test_a_black_emissive_switches_the_converters_glow_off (red first); test_preview_server.py test_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_server pass.
  • 2026-10-17 -- JobQueue: a folder of JSON jobs that one worker process takes in order (file_utils/job_queue.py, root DEFAULT_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 caller submits and waits (failing fast when the worker's heartbeat stops, not at its timeout); the worker serves, 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 worker stopped with the error; a wait with 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's LightLayerBaker worker runs on it.
    • Test(s): new test_job_queue.py (12).
  • 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.py UvPack._separate_islands uses it, as does blendertk's articulated rig). The vectorized min-label hooking UvPack kept 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_pack passes; surface_snapshot.json +1 entry.
  • 2026-10-17 -- A GLB built in a host with no cv2 still ships its lightmaps and reflection probe (file_utils/mesh_convert/_lightmaps.py apply_glb_lightmaps(hdr_codec=), embed_glb_lightmap_probe(hdr_codec=); _fbx2gltf.py fbx_to_glb(hdr_codec=); glb/pipeline.py GlbPipeline.build(hdr_codec=); net_utils/preview/bridge.py new PreviewBridge.hdr_codec(), forwarded by PreviewDeliverer). 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 stays ImgUtils.encode_hdr_pixels_for_web. None keeps 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).
  • 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: new ImgUtils.encode_hdr_pixels_for_web(rgb, percentile=None), ImgUtils.HDR_WEB_HEADROOM; encode_hdr_for_web encodes 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's intensity), 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 explicit percentile keeps the old divide.
    • Test(s): test_mesh_convert.py TestGlbLightmaps new test_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_img 839 passed, 1 skipped.
  • 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/ new LightLayers, LightMix, LightPredictor, ViewerTransform, LookTarget, LookRegion, LookMeasures, LookScore, LookFit, LookSheet, LookMatch; net_utils/preview/look_capture.py new LookCapture; img_utils new ImgUtils.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. A LookTarget names 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), LookMeasures reads them (level, peak, footprint, chroma, hue, warmth; profiles; falloff along and down a quad), LookScore compares, LookFit solves the free weights (bounded log-weight Levenberg-Marquardt), and LookSheet draws the review sheet an agent or a person reads. LookMatch ties a session file together, and to_rig writes 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. LookCapture is 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; serve keeps a template loaded so a frame costs seconds, not the minutes a load takes (stop ends 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, !pattern keeps a lens) to hold the prediction to it -- measured on f3, display p99 2 codes, total light within 0.7%; capture_bake turns a bake folder (mayatk's LightLayerBaker) into a layer. LookMatch.score / sheet judge any display frame (--frame, an exact capture) by the prediction's measures; fit --max-iter now reaches the fit (it was parsed and dropped). A blacked material's reflection really goes: three r163+ reads a scene-lit material's level off scene.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 when LOOK_CAPTURE_TEMPLATE / LOOK_CAPTURE_CAMERA name a template, measured 235 s); test_img.py new TonemapTest, LinearToOklabTest, ResampleAreaTest, WarpQuadTest (10).
  • 2026-10-17 -- Reflection-probe placement lives here, shared by both DCC bakers (geo_utils/probe_placement.py new ProbePlacement, ProbeRays, ProbeSite), plus MeshConvert.embed_glb_lightmap_probe(glb, probe, search_dirs, hdr_codec=None) for a native export's GLB (_embed_lightmap_probe(hdr_codec=)) and MathUtils.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 finds lightmap_web where the viewer reads it.
    • Test(s): test_probe_placement.py new (9), test_mesh_convert +3, test_math +3; snapshot entries added.
  • 2026-10-17 -- ImgUtils.stitch_seams(..., hold=None) and ImgUtils.assemble_atlas(..., canvas=None): writing back into an atlas already shipped (img_utils/_atlas.py, _img_utils.py). hold is 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. canvas places 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.py StitchSeamsTest +5, AtlasAssembleTest +3 (red first; hold ignored 4/4 FAIL, canvas ignored 3/3 FAIL). test_img + test_bake_baseline 414 passed.
  • 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.py cover the declaration; the behaviour is tested by its producer (mayatk test_light_rig.py). sync_scene_records.py regenerated the owner table (22 records).
  • 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.py new FbxMedia.reader_records, FbxMedia.NODE_ENTRIES, read by dependencies / embed_dependencies / extract_dependencies; export_verify.py check_fbx_dependencies; fbx_file.py FbxFile.user_properties(named=)). An assembly export carries each referenced module's NS:data_export as 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 failed fbx_dependencies when 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 unitytk CarrierImport.Collect and 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.py new test_a_module_record_the_scenes_own_shadows_names_nothing, test_a_module_record_names_only_the_nodes_the_file_carries; test_export_verify.py new test_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).
  • 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, new FbxFile.KEY_INTERPOLATIONS). It reads what an importer that sees only transforms leaves behind -- a node's Visibility, 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_curves stays the census; this returns the keys. blendertk's FbxUtils.import_fbx lands them.
    • Test(s): test_fbx_media.py new TakeKeysTestCase (2; build_curve_fbx(user=)); red: method missing 2/2 FAIL, modes mutated 1/2.
  • 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.js quadExtent / place / feedSource, new Z_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's IsZUp rule), 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.py new test_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_web 38 OK.
  • 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.py new BakeBaseline, StaleReport; scene_records.py new private record SceneRecords.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.assess names 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); advance rolls the record forward at each bake, keeping what that bake would otherwise forget. Moments with size-relative tolerances, not a point hash, so a .ma save 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.py new TestLightmapBakeState (2).
  • 2026-10-17 -- audio_manifest is a derived record, so every hand-off refreshes it (core_utils/engines/scene_export/scene_records.py SceneRecords.AUDIO kind=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 no audio_manifest, and after a publish a track added since was missing.
    • Test(s): test_scene_records.py new test_a_handoff_ships_the_audio_its_scene_plays_now (red first); test_fbx_media.py new test_audio_events_reads_the_carrier_the_readers_read (two carriers + module-only; red first). test_scene_records 123 passed.
  • 2026-10-17 -- ArticulationPlan: the plan an ArticulatedRig builds from, host-free (geo_utils/articulation/plan.py, root DEFAULT_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 the articulation record'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 own end.frame layout. The joint orient is read exactly as Maya's MTransformationMatrix reads 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_articulation 31 OK; surface_snapshot.json +ArticulationPlan.
  • 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 input ImgUtils.stitch_seams needs, moved out of mayatk's LightmapBaker._seam_pairs so 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.py new AtlasSeamPairsTest (9; the fold and decal tests each prove their gate by opening it).
  • 2026-10-17 -- AudioUtils.scope_events and FbxMedia.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 each audio_manifest event in the take that plays it private on AudioClips, 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, plus name@frame for 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's CarrierImport.Collect), each name once -- the names a host resolves to files for embed_dependencies(files=).
    • Test(s): test_audio_utils.py new TestScopeEvents (6, red first); test_fbx_media.py new test_audio_events_names_the_tracks_the_manifest_plays (red first). test_audio_utils 46 passed, test_fbx_media 58 passed.
  • 2026-10-16 -- UvTransfer.transfer_materials can rewrite existing maps in place (geo_utils/uv/transfer.py: a job's optional "paths": {channel: [files]}; save_map writes the format its extension names; new WRITABLE_EXTENSIONS, in_place_paths, and layout_jobs(per_material=)). Texture transfer's Overwrite target (mayatk / blendertk TextureTransfer.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 in output_dir; no fresh output is named onto one, and one file named by two channels or outputs raises ValueError before anything is written. in_place_paths is 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 outside WRITABLE_EXTENSIONS (EXR, .tx) with ValueError; PIL used to fail on a 16-bit or RGBA map saved as JPEG.
    • Test(s): test_uv_transfer.py new TestSaveMap (4), TestInPlacePaths (4), 4 in TestTransferMaterialsOutputs and 2 in TestLayoutJobs (red first); test_uv_transfer 118 passed.
  • 2026-10-16 -- AppLauncher.run gives its child an empty stdin (core_utils/app_launcher/_app_launcher.py: stdin=DEVNULL on the captured and output_file paths, as spawn and 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: DuplicateHandle failed 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.py new test_run_starts_when_this_process_has_no_usable_stdin (red first: WinError 6 on both paths).
  • 2026-10-16 -- A lightmapped GLB ships no KHR_lights_punctual lights (file_utils/mesh_convert/_lightmaps.py new MeshConvert.strip_glb_baked_lights; run unconditionally in the fbx_to_glb chain 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 a lightmap_web manifest is present; a GLB without one keeps them.
    • Test(s): test_mesh_convert.py new TestBakedLightsLeaveTheGlb (2; the lightmapped case red first); test_mesh_convert 455 passed, 1 skipped.
  • 2026-10-15 -- The base-colour section carries a material's opacity factor (file_utils/mesh_convert/_materials.py MeshConvert.set_glb_base_color accepts alpha -> 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.py new TestSetGlbBaseColorAlpha (4; 3 red first).
  • 2026-10-15 -- A share's link check takes a guest's route and a browser's trust (net_utils/preview/_sharing.py PreviewServer.ping_share result gains route / verified; _net_utils.py new NetUtils.public_addresses, NetUtils.tls_context; remote_file.py RemoteFile.open verifies with it). Reported: Test Link said a Tailscale link did not answer (certificate has expired) while guests opened it. Two faults. The check verified with ssl.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_context leaves expired anchors out (and adds certifi when installed), and a certificate this machine still cannot verify is asked again unverified, passing with verified: False. And it resolved the name on this machine, where MagicDNS answers a ts.net name with the tailnet address -- a pass proved nothing about the public ingress. It now resolves through public DNS (public_addresses, the A records resolves_publicly already 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.py new TestPublicAddresses (3, a CNAME chain), TestTlsContext (3: expired vs valid anchors, built once, no expired anchor on Windows); test_preview_server.py new test_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.
  • 2026-10-15 -- The scene sidecar's unlit section: a GLB material the DCC authored unlit ships KHR_materials_unlit (file_utils/mesh_convert/_materials.py new MeshConvert.set_glb_unlit, UNLIT_EXTENSION; _sidecar.py SIDECAR_APPLIERS row). 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 mayatk SceneState._read_unlit (shadow-plane materials, surfaceShader).
    • Test(s): test_mesh_convert.py new TestSetGlbUnlit (3: marks only the named material and declares the extension, nothing named leaves the file alone, registered as a section); test_mesh_convert 449 passed, 1 skipped.
  • 2026-10-15 -- Several texture sets' UV islands planned and packed into one atlas (geo_utils/uv/combine.py new UvCombine, CombineShell / CombineGroup / CombinePlan / CombineLayout; geo_utils/uv/pack.py new UvPack.solve_similarity / apply_similarity; scene_export/scene_records.py new PRIVATE SceneRecords.TEXTURE_COMBINE). The planning half of mayatk's Texture Combine, arrays in -> arrays out. UvCombine.clusters groups 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; plan sizes 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; pack packs 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_combine is the record the combine keeps (original UVs, face materials, source maps, settings; never exported).
    • Test(s): test_uv_combine.py new (14: clusters, plan modes, overrides, stack footprint, pack keeps stacks stacked / proportions / hull clusters); test_uv_pack.py new TestSimilarity (4).
  • 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.py new ShotSequencer.swap_shots(retain=), SWAP_FIELDS, copy_shot_content; shot_plan.py new ShotPlanner.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 as move_shot_to_position lays one out -- and a field named in retain (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_reorder builds 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.py new test_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.py new TestPlanOrder (4 + the inherited reorder pins).
  • 2026-10-14 -- A Scene Export that ships a GLB ends with an Open <name> in WebXR link (core_utils/engines/scene_export/scene_exporter.py new SceneExporterBase._glb_created; net_utils/preview/bridge.py new PreviewBridge.preview_file). The host reports each GLB that lands through _glb_created, which logs GLB created as before and keeps the path. _close_file_logging then writes an action://preview link 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) is publish_file for 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.py FbxMedia.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 reads not 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 assembly list in both deliverables, the FBX's lighting recipe in the FBX's own addresses (core_utils/engines/scene_export/scene_records.py new RecordSpec.assembly, SceneRecords.HANDOFF_ASSEMBLY, HANDOFF record v2, HANDOFF_INSTRUCTIONS rewritten; file_utils/mesh_convert/_sidecar.py new MeshConvert.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's rendering was the GLB's text word for word, so its environment pointed at extras.lightmap_web.probe -- an address no FBX has -- while the probe rode data_export.lightmap_metadata, embedded; it now names that record, its OpenEXR, its scene units and unit_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's data_export1 with its own older handoff (maps "NOT embedded", intensity "the multiplier restoring the bake's range"), a stale lightmap_metadata (six objects since removed, _LightMap where the embedded copy is _Lightmap, no probe), and 16 <object>__opacity / __highlight proxies plus a visibility_tracks highlight ramp no description named. The text now states the readers' own rule -- per record the node named exactly data_export wins, another carrier's copy is read only where it has none, and a handoff describes 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; assembly lists the approved scene part by part (the content, each record present from its own RecordSpec.assembly, then the environment, key light, lightmapped-material and renderer rows), each with the data it is built from, and visibility_tracks' description names visibility, opacity and highlight with its colour stops. The GLB envelope gains the same assembly (its sections stated as already applied). Declarative throughout: what the scene is, never an action outside the file. mayatk/docs/data_nodes.md regenerated (sync_scene_records.py).
    • Test(s): test_scene_records.py TestHandoff +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.py TestFbxHandoff +3 (test_publishes_the_glb_policy_in_its_own_addresses replaces 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).
  • 2026-10-14 -- The Scene Exporter keeps ONE log per scene in the per-user cache, never a .log beside each deliverable (new RunLog, core_utils/logging_mixin/run_log.py; core_utils/engines/scene_export/scene_exporter.py new SceneExporterBase.log_file_path, LOG_FOLDER, _close_file_logging, _remove_legacy_logs, setup_file_logging(header=), close_file_handlers(footer=); generate_log_file_path deprecated, removed in 0.17.0). Each export wrote <export name>.log into 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>.log in UserConfig.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 .log named 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. RunLog is the general primitive (file_name, attach / detach, trim, sweep), one classmethod per step.
    • Test(s): test_scene_exporter.py TestRunLog (8; red: two exports left two logs beside the deliverables; a GLB-only run's header named its temp FBX), test_scene_exporter.py test_the_log_keeps_debug_detail_the_panel_never_shows; test_logging_mixin.py RunLogTest (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.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.