-
2026-10-04 -- Importing a photogrammetry or video tool no longer turns OpenEXR off for the process (
img_utils/exposure_equalizer.py,image_curator.py,vid_utils/frame_extractor.py). OpenCV readsOPENCV_IO_ENABLE_OPENEXRonce, when cv2 loads (measured, opencv-python 4.13). These modules imported cv2 at their top without it, so whichever loaded first left every later EXR read or write in the session failing -- a DCC that opened a photogrammetry panel before a lightmap bake could not encode its maps. Each sets the flag first, asImgUtilsdoes. Test:OpenExrFlagTest(every module-level cv2 import in the package), red on HEAD. -
2026-10-04 -- A take with no curves fails the channel gate instead of passing as absent (
file_utils/mesh_convert/export_verify.py).check_fbx_take_channelsskipped every declared taketake_curvesdid not list, and a stack with no curves is not listed, so a take whose split lost every channel passed both FBX take gates and an all-hollow file SKIPped. A take is absent only when no stack has its name; a hollow one FAILs against a whole-timeline take, naming the lost channels, and without one (Blender) it is a still shot. Tests:test_a_take_with_no_curves_fails,test_a_take_with_no_curves_is_legal_without_a_whole_timeline_take. -
2026-10-04 --
FbxFile.take_curveskeeps same-named targets and layered curves apart (file_utils/mesh_convert/fbx_file.py). Keys were display names, so a later curve replaced an earlier one -- 478 repeated Model names in a 12-shot Maya FBX, nameless NodeAttributes, two layers on one channel -- andtake_spans, which sets the origin every GLB clip is cut against, measured whichever curve was written last (1-100 read as 5-50). A name that is not unique is labelledname#uidand a multi-layer channel is unioned; on the production file this recovers 45 channels inTake 001and 27 per shot. Tests:test_same_named_targets_and_layered_curves_all_count,test_a_twin_s_channel_lost_to_the_split_fails. -
2026-10-04 -- A truncated or corrupt FBX raises
ValueErrorwherever it is cut (fbx_file.py,_fbx2gltf.py,glb/pipeline.py). A cut inside a zlib key array raisedzlib.error(elsewherestruct.error) past every(OSError, ValueError)catch -- out ofconversion_timeoutbefore a conversion was even attempted -- and a cut where nothing is decoded parsed silently, socheck_fbx_containerPASSed it. Both now raiseValueError("Truncated or corrupt FBX: ..."), including a record that runs past the end of the file. Tests:test_a_truncated_file_is_refused_wherever_it_is_cut,test_a_truncated_fbx_fails_its_container_gate_with_the_reason,test_a_truncated_fbx_degrades_the_budget_rather_than_raising. -
2026-10-04 -- The measured clip-span stamp leaves other carriers' channels alone (
_animation.py,_sidecar.py)._stamp_clip_spanswrote throughoverlay_data_export, which clears the channel from every carrier, so each conversion dropped a referenced module'svisibility_tracks. It now writes back into the carrier it read from. Test:test_the_stamp_leaves_every_other_carrier_s_channel_alone. -
2026-10-04 --
fbx_to_glbparses its input once (_fbx2gltf.py). The timeout budget and the measured spans each parsed the FBX, about 8 s apiece on a 133 MB assembly;MeshConvert.conversion_timeoutandbake_node_framestakefbx=. Test:test_the_input_is_parsed_once_for_the_budget_and_the_clip_spans. -
2026-10-04 --
check_clip_originjudges the span the GLB was cut against (export_verify.py). It judged the producer's prediction and FAILed correctly cut files. It now reads the span the GLB carries (falling back to the sidecar's) and WARNs on a producer disagreement, naming both spans. Tests:test_clip_origin_judges_the_span_the_file_was_cut_against,test_clip_origin_fails_a_file_cut_against_a_span_its_stack_lacks. -
2026-10-04 --
FileUtils.move_fileoverwrites on one volume with one rename (file_utils/_file_utils.py). The 20-character.<hex>.movingstage is longer than a short destination name, so near a 260-character path in a host that is not long-path aware (Maya) it could not exist and the overwrite failed. A same-volume overwrite is now onereplace_file; only a cross-volume (EXDEV) move is staged. Tests:test_a_same_volume_overwrite_stages_nothing; the two cross-volume tests are pinned to that branch. -
2026-10-04 -- The WebXR preview serves its modules as JavaScript whatever the Windows registry says (
net_utils/preview/routes.py,_serve_root.py,.github/workflows/publish.yml). The stdlib typed each served file throughmimetypes, which on Windows readsHKCR\.js\Content Type; an installer that left that attext/plainblanked the whole viewer, because a browser will not run a module script typed as anything but JavaScript._PreviewHandler.extensions_mapnow names.js/.mjs,.json,.wasmand.glbitself, on the owner's and the guest's listener; the server warns when an install carries the page without itskernel/, and the release workflow's installed-wheel check requireskernel/main.jsandfeatures/shadow_rig/model.js. Tests:test_the_kernel_is_typed_as_javascript_whatever_the_registry_says,test_a_page_installed_without_its_kernel_says_so. -
2026-10-04 -- The WebXR page says what stopped it from starting (
net_utils/preview/viewer.html). The boot watchdog blamed unpkg.com for any kernel that did not start (its flag is set only once every module has run), so no WebGL context, or a kernel module missing or refused for its type, all read "three.js failed to load — needs unpkg.com". It now records the first failure: a module that threw is shown with its message, and one that never arrived is reported as the kernel's unless three.js itself also fails to load; a blocked or silent CDN keeps the CDN message. Test:test_a_page_that_cannot_start_says_what_stopped_it. -
2026-10-04 -- A window's bar-button badge is kept per job:
setBadge(text, owner)(kernel/windows.js,kernel/api.js,features/snapshot.js,features/playblast.js). The still and the playblast share the Export button and each cleared its badge outright, so a still saved during an encode took "encoding" off the bar with the encode still running. Each owner now sets and clears its own badge;setBadge(text)without an owner behaves as before. Test:test_a_still_saved_during_an_encode_leaves_the_encodes_badge. -
2026-10-04 -- The Environment window states the level the reflection probe plays at (
kernel/environment.js). It said "1/π: the bake's units" whatever the deliverable published; it now printsenvironment.probeIntensity, labelled 1/π only at the default. Test:test_the_environment_window_states_the_level_the_probe_plays_at. -
2026-10-04 -- Preview housekeeping, and CI type-checks the JavaScript on pythontk's own pushes (
features/articulated_rig/model.js,.github/workflows/tests.yml,server.py,docs/webxr_preview.md).check_js_types.pyis green again (the articulated-rig_heldreturn is typed) and a newjs-typesjob runs it for pythontk, which m3trik's CI alone never covered. The first load waiting for the scripts a push names is driven in a browser instead of pinned as text (test_a_script_named_with_the_asset_is_in_before_the_asset_loads); stalepreview/scripts/paths and "does not await" claims from thefeatures/move are corrected; a caller's script may import a feature's model only while that feature is active. -
2026-10-04 -- Removing a shot leaves its claims to no shot, so the next shot given its id no longer inherits them (
core_utils/engines/shots/shot_model.py,shot_ledger.py,shot_sequencer.py,shot_transfer.py).ShotStore.remove_shotdropped only the record, anddefine_shotnumbers a new shotmax(ids)+1, so the next shot built took the removed shot's id and its behavior claims, and its Build deleted those keys as behaviors its doc had dropped -- the Shot Manifest's orphan removal and Delete All Shots in both DCCs.remove_shotnow disowns the claims: the keys stay in the scene, owned by no shot, and undo gives them back.remove_stale_shotsdrops behavior claims as well as bound samples,ShotEditLedger.record_authoredtakes over a claim no shot owns on its frame,ShotTransfer.mergelands a claim no incoming shot owns as owned by none, and loading data saved before this fix leaves a removed shot's claims to no shot (ShotEditLedger.disown_absent, new, run byShotStore.from_dict). Tests:test_a_removed_shot_leaves_its_claims_to_no_shot,test_remove_stale_shots_drops_their_behavior_claims_too,test_a_disowned_claim_passes_to_the_next_key_on_its_frame,test_a_loaded_claim_no_shot_owns_is_owned_by_none,test_a_removed_shots_keys_never_pass_to_the_shot_that_takes_its_id,test_a_claim_no_incoming_shot_owns_arrives_owned_by_none. -
2026-10-04 -- A frame-rate change retimes the edit ledger's claims with the shots:
ShotEditLedger.retime(ratio, offset=0.0)(new) (shot_ledger.py,shot_model.py,shot_transfer.py).ShotStore.rescale_to_fpsleft every claim on the old clock, so each named a frame its key had left. The store's rescale and the hand-off's clock change now share one retime. Test:test_rescale_to_fps_moves_every_claim. -
2026-10-04 -- Shot Manifest fixes (
manifest/manifest_engine.py,manifest_model.py,behaviors/_behaviors.py).- Apply on an audio row keys its clip again:
reapply_objectresolved the track name as a scene node. - Assess reports a stale behavior with animator keys on its channel as
behavior_conflict, not "Build re-keys it", since Build's guard leaves it. - One object on two rows of a step is one object. Assess and Apply placed each row alone, and an Apply deleted the other row's keys; a shot an older build listed per row reads back as one object.
- Clearing a pass-through cell in the doc clears it on the shot; a doc without that column keeps the value.
- A behavior whose old keys could not be released is not keyed again.
- Tests:
test_an_audio_row_is_keyed_by_its_track_name,test_a_stale_behavior_over_animator_keys_is_a_conflict,test_an_object_on_two_rows_is_one_object,test_an_object_on_two_rows_reapplies_where_its_build_keyed,test_an_object_an_older_build_listed_per_row_is_one_object,test_a_cleared_pass_through_cell_leaves_the_shot,test_a_behavior_whose_release_failed_is_not_keyed_again.
- Apply on an audio row keys its clip again:
-
2026-10-04 --
StrUtils.common_name: Blender's numbered duplicates share their stem, and a candidate with no legal character gives way to the next (str_utils/_str_utils.py).Cube.001+Cube.002now givesCube(wasCube_001), and a group name written wholly in another script no longer makes the answer""when the items' own names are legal. Tests:test_a_candidate_with_no_legal_character_falls_back,test_numbered_duplicates_share_their_stem. -
2026-10-04 --
ImgUtils.rasterize_uv_triangles: scratch is bounded however tall the triangles are (img_utils/_rasterize.py). Every (triangle, row) span was built at once, so scratch grew with the triangles' SUMMED height: 2048 tall strips at 2048 x 4 took 2.0 GB. Spans are now built a row band and a triangle chunk at a time (_SCANLINE_CELLS,_SCANLINE_SPANS): 0.2 GB, the same coverage bit for bit, the same speed. Tests:test_scratch_does_not_grow_with_the_triangles_summed_height,test_bands_and_chunks_cover_what_one_pass_does. -
2026-10-04 -- An integer image comes back rounded and saturated, never wrapped, from
ImgUtils.extrapolate_fill,stitch_seams,denoise_imageandassemble_atlas(img_utils/_filters.py,_atlas.py). Each worked in float and cast back with a bareastype: an 8-bit ramp extrapolated past 255 wrapped (60, 130, 200 -> 270, stored as 14), a stitched tap came back 79 where the solve said 335, and every fractional result was truncated toward dark. Tests: the fourtest_an_8bit_*cases. -
2026-10-04 --
UvTransfer.resample_lightmapsreads each source map once however the jobs interleave (geo_utils/uv/transfer.py). Only the last map read was kept, so jobs on maps a, b, a, b read four 4K EXRs where two serve. Names are still assigned in the jobs' order. Test:test_interleaved_source_maps_are_each_read_once. -
2026-10-04 --
ImgUtils.convert_scene_linearknows every name Blender 5.1 gives its scene-linear spaces (img_utils/_color_space.py).Linear DCI-P3 D65,lin_rec709_srgb,Linear ACEScg,Linear ACES,Linear BT.709/BT.2020and thelin_*_scenenames raisedKeyError. The docstring now says nothing is clipped: an out-of-gamut colour comes back negative. Test:test_an_aces_config_name_resolves_to_the_same_space. -
2026-10-04 --
UvTransfer.transfer_materials(avoid=): a texture transfer never writes over a map a kept source material reads (geo_utils/uv/transfer.py). Maps went to<output_dir>/<stem>.pngunchecked, so a re-run overwrote a kept source material's files beyond recovery. Names now go throughFileUtils.unique_path: an avoided path takes<stem>_<k>.png, and two outputs of one run no longer share a file. Tests:TestTransferMaterialsOutputs(3). -
2026-10-04 --
ArticulationModel.solve: a target out of reach no longer makes the arm thrash (geo_utils/articulation/model.py,net_utils/preview/features/articulated_rig/model.js). Past the reach, the damped least-squares step overshot. Every solve then landed on a different flail, and a dragged target whipped the arm about, both solved from rest (Maya's end control) and from the last frame's pose (a runtime's hand grab). Joints jumped 12-29 units on a 0.46 step of the target on the conformance arm, and 15-34 on the production magnifier. Now a step that would leave the held point further from its target is halved until it helps (HALVINGS = 6), and when none does the solve stops: the arm settles where it comes nearest. In reach, 296 of 300 cold grabs on the magnifier solve as before, and the other 4 land as well. The JavaScript port, unitytk's C# port and mayatk's MEL port take the same step, and every port passes the conformance cases again: the browser (20 passed), Unity (25 passed, 4 env-skipped) and Maya. New test:test_articulation.test_a_target_dragged_out_of_reach_moves_the_arm_smoothly, red on the old step (14-32 jumps per drag, now 0-1). -
2026-10-04 -- A GLB conversion measures where each clip opens from the FBX it reads, instead of trusting the producer's prediction; a shot scene that keys no visibility gets its shot clips;
FbxFile.take_spansis new (file_utils/mesh_convert/fbx_file.py,_fbx2gltf.py,_animation.py,_visibility.py,glb/pipeline.py,export_verify.py). FBX2glTF sizes every take by the key extent of ALL its curves and rebases the clip onto the first of them. Measured on 0.13.1: a camera's focal length keyed at frame 3 opened a take whose transforms start at 10, and the take's own declared 1-120 span was ignored. The shot cut (apply_glb_clips) and the visibility and fade gates placed each clip against the producer's publishedclip_span, a prediction of that number made before the file existed. Three measurements on 2026-10-04 showed it wrong or missing:- A shot scene that keys no visibility published no origin at all:
build_visibility_trackscleared the record with no tracks. The rebuild declined after the converter's shot takes had been dropped, and the GLB shippedTake 001alone (clips_vs_takesFAIL). - A mayatk hand-off (WebXR preview, bridge send) published the bake range the LAST export left in the FBX plugin: shots cut 23 frames early.
- blendertk published each take's first authored key, while Blender bakes the take's whole window: a gate placed against it lost ShotB's visible run (hidden from frame 21, not 30).
- The fix.
fbx_to_glbreads its input'sFbxFile.take_spans()(each take's first and last key, in seconds) and stamps them onto the GLB'sclip_span(MeshConvert._stamp_clip_spans), right after any caller overlay and ahead of every pass. A declared take is stamped under its name;*comes from the one stack the file does not declare. A disagreement with the published value is logged, naming both. A visibility channel the caller overlaid (an effect preview, whose ramp rides its own extent) is left as stated. A file that cannot be read keeps its published spans. - Producers still publish.
build_visibility_tracks([], clip_spans=...)returns an origin-only envelope (tracks: []), so a shot scene keying no visibility still states its origin for readers of the FBX. It lives on this DERIVED record, not on the AUTHORED shot record, which a hand-off does not refresh.MeshConvert._published_originis the one reader of the published value (apply_glb_clips,ExportVerifier.check_clip_origin). - The converter's input.
GlbPipeline._drop_split_takesdrops the converter's shot takes whenever a whole-timeline stack survives to cut them from, since the rebuild's origin is now measured. - Timeout budget.
MeshConvert.bake_node_framescounts each take's measured key extent, falling back to the declaredLocalTime. The declared span undercounted every take whose keys outrun it, which is the case that discards a finished conversion. - Tests:
test_take_spans_is_each_take_s_key_extent_over_every_curve,test_take_spans_leaves_out_a_take_it_cannot_measure, fiveTestApplyGlbClipsmeasured-origin cases (stale published origin, a declared take's span, no channel, agreement is quiet, two undeclared stacks),test_an_origin_alone_is_published_for_a_scene_with_no_tracks,TestDropSplitTakes,test_the_bake_is_each_take_s_key_extent_not_its_declared_span.
- A shot scene that keys no visibility published no origin at all:
-
2026-10-04 -- An FBX's takes can be judged channel by channel, and the deliverable gate does it:
FbxFile.take_curves,ExportVerifier.check_fbx_take_channels(file_utils/mesh_convert/fbx_file.py,export_verify.py). Maya's take split dropped every curve with no key inside a shot, so Unity played those nodes at their rest pose for the whole shot. The only FBX take gate checked that each take name existed. mayatk now resamples the split, and this gate is what keeps it honest.FbxFile.take_curves()returns{take: {(target, property, channel): (first_key, last_key, key_count)}}in FBX ticks: what each take animates and over which keys.FbxFile.TICKS_PER_SECONDis the 7.x tick rate both DCCs write;MeshConvert's conversion-cost estimate now reads it instead of a private copy.FbxFile.load(..., span_arrays=("KeyTime",))reads only those records' arrays, and only their first value, last value and count. A production file's per-frame keys are never built into lists.decode_arraysalso accepts record names now, inflating only those.ExportVerifier.check_fbx_take_channelschecks each declared take against the whole-timeline take: it must animate every channel that take does, keyed across its declared window. A take missing a channel fails, naming it, and so does one keyed short of its window. A file without a whole-timeline take (Blender windows each take itself) is held to the windows alone, and Full Sequence Only is skipped.- The verifier now reads its FBX without embedded media (
raw_payloads=False); no gate read the media. - New:
ExportRun.bake_range_mode, the Bake Range row's value, derived inwith_tasks. mayatk predicts the whole-timeline take's start from it before the range is set. - Tests:
test_export_verifyTestFbxTakeChannels(5), plusTestFbxFile's take census with spans compared against a full decode. The FBX fixture writer gains zlib-encoded int64 arrays, as Maya writes them.
-
2026-10-04 -- The WebXR preview sorts its controls into category windows, gains an Environment window with the reflection probe's switch, and Inspect counts what the probe costs (
net_utils/preview/kernel/windows.jsandenvironment.js, new;kernel/api.js,scene.js,probe.js,lightmaps.js,specs.js,load.js,dom.js,viewer.html;features/inspect.js,turntable.js,snapshot.js,playblast.js,articulated_rig/articulated_rig.js).- One bar button per category. View, Environment, Inspect, Export and Rig each open a window, and every part of the page that files under one has a section there: the turntable's switch (View), the probe's (Environment), the profiler (Inspect), the still and the playblast (Export), each articulated rig's sliders (Rig). Each script used to add a bar button of its own.
viewer.window(category)is the seam, published:section(title)withsetRows,addButton,addSlider,addToggleandremove;show,toggle,onShow,setBadge.addToggleis onaddPaneltoo.addButtonandaddPanelstill work. A window shuts from its bar button or its own ×. The Export button carries a running still's or playblast's badge while its window is shut. The rig panel can now be shut and reopened. - The Environment window says what lights the model: the probe's size in the file, where it was captured, its box and which faces are open, or the studio. Its Reflection probe switch turns the probe off and on. Off is exactly the same file shipped without one: the studio, and the materials back on their own programs. Show capture point and box draws both over the model without becoming part of it. Each load starts with the probe on; the HUD says "probe off" while it is not.
viewer.environment(probe,setProbe,maps) is the same for scripts. - The studio is freed while a probe lights the model, and built again when needed. Each prefilter's generator is now freed after use, so its scratch target, as large as the map, no longer stays in memory. Before, a desktop probe held 6.3 MB of its own beside the studio's 6.3 MB and a 6.3 MB scratch target; now it holds only its own 6.3 MB.
- Inspect counts the probe. GPU memory has an environment row: each map, and whether it is idle. File has the probe's bytes, which used to fall into an unshown "other". Load has its decode and prefilter, a part of setup. Model says whether it is on.
specs.probeandspecs.load.probeMscarry the same. Frame time with the switch on and then off is the probe's per-pixel cost on the device. - Tests:
test_preview_viewer_livetest_the_environment_window_turns_the_probe_off_and_on,test_the_studio_comes_back_for_a_file_without_a_probe,test_inspect_counts_the_probe_in_memory_the_file_and_the_load,test_the_bar_carries_one_button_per_category,test_the_probes_capture_point_and_box_are_drawn_on_request. The Export, Inspect and guest tests now drive the windows. Two page-text pins were updated: the studio's prefilter, and the policy read ordered within the load path.
- One bar button per category. View, Environment, Inspect, Export and Rig each open a window, and every part of the page that files under one has a section there: the turntable's switch (View), the probe's (Environment), the profiler (Inspect), the still and the playblast (Export), each articulated rig's sliders (Rig). Each script used to add a bar button of its own.
-
2026-10-04 --
ShotEditLedger.rename_curve(old, new)re-keys every claim on a renamed curve (core_utils/engines/shots/shot_ledger.py). For a DCC whose curve key is built from its owner's name -- blendertk's"<object>|<path>|<index>"-- which goes stale on a rename while the claims still stand. Claims already on new are kept, without duplicating one at the same time. Used by blendertk's rename-followingreconcile_all_shots. Tests:test_shots_coreTestEditLedgerRenameCurve. -
2026-10-04 -- A failed export check is decided where it fails: override it, override it and every later one, or stop. Also new: a tile-set-aware file rename, and image facts read from headers only (
core_utils/task_factory.py,core_utils/engines/scene_export/scene_exporter.py,file_utils/tiled_path.py,img_utils/_img_utils.py,img_utils/_image_header.py).TaskFactory.failed_check_handler(check, messages, remaining)is asked as each check fails, with the checks still to run, and answersCHECK_OVERRIDE,CHECK_OVERRIDE_ALLorCHECK_ABORT. An override carries the same run on, so the tasks and checks below the failure still run. Override All lets every later failure through without asking (each is still logged). No handler, or any other answer, stops the run exactly as before.run_tasksis True when every failure was overridden;_last_overridden_checksnames them.SceneExporterBase.decide_check_failure(check, messages, remaining)is the seam a panel overrides with a dialog. Headless, it asksconfirmabout this one check.check_failure_htmlbuilds the dialog body (the failure, what it reported, the checks still to run, what was already overridden, what each button does), andcheck_labelnames a check by its panel row._run_task_pipelineruns the tasks for both DCC exporters. Previously the override was asked once, after the run had dropped every check below the failure, so accepting it shipped a file whose remaining checks were never made.- Deprecated:
SceneExporterBase.confirm_check_override(removed in 0.14.0), replaced bydecide_check_failure. Removed: the private_resume_skipped_tasks, which nothing calls any more. - New:
TiledPath.rename(path, new_name, dry_run=False)renames a file, or every tile of a token pattern with each tile keeping its number. It never renames over another file, and puts back a set that fails part-way. - New:
ImgUtils.get_image_mode(path)returns the channel layout from the header only: PIL's mode name, an OpenEXR's channel list ("RGBA;16F"), or"RGB;32F"for Radiance.ImgUtils.texture_facts(path, fields)returns the size, dimensions and mode of a file or tile set, reading only the fields asked for. It never opens an online-only placeholder and caches per file version.
-
2026-10-03 -- One effect recipe per scene decides how render effects and audio clips are keyed:
EffectRecipe(new), held by the shot store. The Shot Manifest builds with it, and Assess flags what an older recipe keyed (core_utils/engines/shots/effect_recipe.pynew,shot_model.py,shot_ledger.py,shot_transfer.py,manifest/behaviors/,manifest_engine.py,manifest_model.py,scene_export/scene_store.py). The manifest keyed its own 10-frame highlight and 15-frame fades from the behavior JSON, while the Render Effects panel keyed a 2.86 s pulse from per-user settings. One effect had two shapes, and Build could not read the panel.-
ptk.EffectRecipeis a frozen record:- the fade length, in frames;
- the pulse's period, duty, ramp, leads and colours. Times are in seconds, so a pulse keyed at 24 fps beats like one keyed at 30;
- the audio whole-frame snap.
plan(effect, start, end, fps)gives the keys an effect writes.windowgives where a template places them,envelopethe effect's shape for readers that do not key, andfingerprint(effect)the stamp its keys carry. -
ShotStore.effect_recipeholds the recipe with the scene, andShotTransfercarries it.update_effect_recipe(**changes)edits it and announces the change.ShotStore.watch_settings(callback)follows the active store's settings across a store swap and returns the unsubscribe; the panels bind to it. -
The shipped templates (
fade_in,fade_out,highlight,set_clip) name aneffectand aplaceinstead of spelling out keys, andBehaviorSpecvalidates both.Behaviors.keyed(behavior, recipe, fps)gives every reader the template's envelope. A custom template keeps itsattributes. -
Behaviors.apply_to_shotsis pure and shared; the hosts' versions are now wrappers. It runs the audio pass first, then the guards, then releases every claimed key, then applies. -
A behavior key's ledger claim carries the stamp of the recipe it was keyed under (
record_authored(stamp=),authored_stamps,authored_pairs). Assess reports:- an object an older recipe keyed, as
stale_behavior(ObjectStatus.stale_behaviors); - a shot that holds keys of behaviors its doc dropped (
StepStatus.dropped_behaviors).
StepStatus.needs_buildsays when a Build would change a step. Build re-keys a stale behavior.ShotManifest.release_dropped, run bysync, deletes the keys of a dropped behavior; locked shots, and shots no step pairs with, keep theirs.reapply_objectgives back the keys of behaviors its object no longer lists. - an object an older recipe keyed, as
-
ShotManifest.adopt_placed_clipsclaims a clip that a build keyed before builds claimed their clips. It claims the clip where a build puts it, before any shot moves, so the next build moves the clip instead of keying a second copy. -
ShotTransfercarries authored claims across a hand-off. It used to remap only the step and key registers, so a Build in the crossed scene keyed every behavior again beside its old keys. -
SceneStoreBase.owner(key)returns the classOWNERSnames for a record, orNone. This is how a lower layer, such as the render-effect and audio tools, reaches the shot store without importing upward. -
Tests:
test_effect_recipe(new),test_shots_core(TestStoreEffectRecipe,TestLedgerRecipeStamps),test_shots_manifest_core(TestRecipeOwnership),test_shot_transfer(TestAuthoredClaims, red before),test_scene_records(owners).
-
-
2026-10-03 -- Saving a shot store no longer writes the user's cross-scene prefs (
core_utils/engines/shots/shot_model.py).ShotStore.save()called_save_user_prefs(), so every save rewroteprefs.jsonin the user's config folder with that store's values. That included every test that saved a throwaway store, so a test run reset the user's prefs to the defaults. Now only the panel that changes a preference writes it. Test:test_shots_coretest_a_save_never_writes_the_users_prefs(red before). -
2026-10-03 -- A lightmapped deliverable carries the room its bake lit as a reflection probe, and the WebXR viewer lights with it (
file_utils/mesh_convert/_lightmaps.py,_sidecar.py,img_utils/_img_utils.py,core_utils/engines/scene_export/scene_records.py,net_utils/preview/kernel/probe.jsnew,lightmaps.js,scene.js,load.js,specs.js). Reported on a production soldering table: its metal button housing and pedestal rendered darker than in Maya, its unbaked buttons flat white, the magnifier's lens solid. Measured per object against Arnold's render of the same room from the same camera: the housing at 0.12 of Arnold's luminance, the pedestal 0.60, the trays 0.36-0.44, the unbaked magnifier up to 2.8x too bright. Cause: a lightmap is diffuse irradiance and a metal has no diffuse, so a baked metal showed only the studio environment's reflections, held to the export's level against a studio far brighter than the room; an unbaked object took that studio whole.MeshConvert.apply_glb_lightmapsembeds a manifestprobe-- the host bake's HDR of the room, its capture point and projection box in scene units besideunit_scale-- as Radiance bytes in a bufferView, published asextras.lightmap_web.probein metres (_embed_lightmap_probe; newImgUtils.encode_hdr_radiance). A probe it cannot find is warned; the lightmaps still ship.- The viewer (
kernel/probe.js) loads it as the environment in place of RoomEnvironment: PMREM-prefiltered, read in the model's own axes (a world-to-model matrix synced each frame, so layout and the turntable keep it right), reflections box-projected onto the room, atenvironment.probeIntensity1/pi -- the lightmaps' unit, a white card's radiance three.js reads as irradiance. Its reflections play whole:lightmappedMaterials.envMapIntensityis the studio's level.specs.lightmaps.probeand the HUD say when it is up; one the page cannot read is reported and the studio stays. - A baked material's lightmap enters as an image-based light's irradiance (
iblIrradiance,lightmappedMaterials.lightMapEnergy): the diffuse it lights weighted by one minus the specular's share, plus multiscatter, where it was added at full weight beside the reflection -- lit twice where a surface reflects most. - Measured end to end against Arnold -- a probe bake of the production scene, the preview's own GLB push, the real page, the same camera: housing 0.12 -> 0.62, pedestal 0.60 -> 0.78, trays 0.36-0.44 -> 0.89-1.07, the unbaked magnifier's parts from up to 2.8x to 0.65-1.45x (its lens 1.54 -> 1.01, now blended: mayatk 2026-10-03), the room's walls and floor 1.00-1.03. What remains on metal is three.js' metal model against Arnold's: the same probe lighting both measured 0.8-0.9.
SceneRecords.LIGHTMAP_PROBE(private) records a host's probe; thelightmap_metadatadescription names it.MathUtils.metres_per_unitreads the one length table, andRigVerify.UNIT_METRESnow derives from it.- Tests: test_mesh_convert
test_a_manifest_probe_ships_as_radiance_hdr_in_metres,test_the_probe_survives_bin_compaction,test_a_probe_it_cannot_find_is_warned_and_the_lightmaps_still_ship,test_radiance_encode_keeps_the_range_and_drops_what_rgbe_cannot_hold; test_preview_viewer_livetest_a_baked_metal_reflects_the_rooms_own_probe_not_the_studio,test_a_probes_reflections_play_whole_whatever_the_studios_level,test_a_probe_reflection_is_box_projected_onto_the_room,test_a_probe_the_page_cannot_read_is_reported_and_the_studio_stays; test_preview_server pins the recipe's probe and energy rules; test_mathtest_metres_per_unit_reads_the_one_length_table.
-
2026-10-03 -- An edited user behavior template is read again without a restart (
core_utils/engines/shots/manifest/behaviors/_behaviors.py). The validated load was cached per NAME for the life of the process, so a user template added over a built-in that was already loaded, or edited later, changed nothing until restart. The cache is now keyed on the resolved file: its tier and modification time. Test:TestBehaviors.test_an_edited_user_template_is_read_again(red before). -
2026-10-03 -- The cross-scene shot prefs are written atomically (
core_utils/engines/shots/shot_model.py).ShotStore._save_user_prefsrewritesprefs.jsonon every save with a plainwrite_text; cut off mid-write, it left a truncated file the reader discards, and the preferences with it. It now goes throughUserConfig.save_file. -
2026-10-03 -- A manifest highlight beside another behavior keeps its ramps;
Behaviors.anchor_overridesis new (core_utils/engines/shots/manifest/behaviors/_behaviors.py,manifest_engine.py). An object's behaviors were each given an anchor spread 0.0 .. 1.0 over ALL of them, and the anchor was forced onto every block of the template. Ahighlighthas an in AND an out ramp, so beside a fade both collapsed onto the shot's first frames -- keys (100, 1), (110, 0): a glow held for the whole timeline before the shot -- and afade_inlisted after it landed mid-shot.anchor_overridesis now the one placement rule: point behaviors (one phase) spread in doc order, a span keeps its own anchors.ShotManifest.reapply_objectandassessuse it, as do both hosts' builds.reapply_objectalso releases every behavior's old keys before re-keying any: one at a time, a later release deleted a key an earlier behavior had just written on a shared frame. Tests:TestBehaviors.test_anchor_overrides_spread_points_and_leave_spans_whole,test_a_highlight_beside_a_fade_keeps_its_ramps_apart,TestReapplyObject(red before). -
2026-10-03 -- Records declare their payload shapes, and every other language's copy is generated from the declaration (
core_utils/schema_spec.py,core_utils/engines/scene_export/scene_records.py,geo_utils/articulation/record.pynew). New API:SchemaSpec.TYPED: aTYPED = Truespec checks each field against its type hints --Literal,Optional,Tuple,Listof nested specs. A bool never matches an int.SchemaSpec.json_schema(): emits JSON Schema 2020-12. It refuses two specs of one name, and a typed annotation that disagrees withnested=.WebProjection(key, version, shape): a record's web manifest key, version and shape, held by the newRecordSpec.webfield (besideRecordSpec.shape, the payload's own shape).SceneRecords.shape(),web_shape()andweb_projected().ArticulationRecordandArticulationWeb.
The articulation payload is the pilot.
ArticulationRecorddeclares what both DCC producers write.ArticulationWebis its WebXR projection, with node indices.PreviewServer.AUTO_SCRIPTSand theMeshConvertmixins (articulation, shadow rigs, lightmaps, animation) now read their web keys and versions from the declaration instead of spelling them out.m3trik/scripts/sync_scene_records.pygenerates, and checks:net_utils/preview/kernel/records.js;- unitytk's
ArticulationRecord.g.cs.
Tests:
test_schema_spec(TypedValidateTest,JsonSchemaTest),test_scene_records(TestContracts),test_articulation(TestRecordShape). -
2026-10-03 --
Conformance: a registry of the golden cases every port of a model is held to;ShadowConformanceis new (core_utils/conformance.pynew,geo_utils/shadow_projection.py).ptk.Conformance.cases(name, seed=0, **options)returns a model's cases by name.names()andprovider()come with it, and an unknown name lists the registered ones. The two entries:articulation(ArticulationConformance), the first.shadow_projection, the second.ShadowConformance.cases(seed, per_kind)turns the shadow projection's in-test assertions into a reusable document of nine named cases plus random point and sun ones. Each case holds its input, source, model terms and placement. The per-term tolerances inTOLERANCEwere measured on the browser port.
The WebXR shadow model and unitytk's C# runtime are now both held to these cases.
-
2026-10-03 -- The WebXR viewer is a thin page over ES modules:
kernel/is the viewer andfeatures/holds the scripts, both served as written (net_utils/preview/).viewer.htmlkeeps its markup, CSS and import map, and loadskernel/main.js, the composition root. Its former inline module is now one kernel module per part, plus the shared vector/quaternionkernel/math.jsand the generatedkernel/records.js.Layout.
- The built-in scripts moved from
scripts/tofeatures/. The shadow rig and the articulated rig are folders, each with a host-freemodel.jsbeside its three.js entry. - The server mirrors that layout:
PreviewServer.SCRIPTSholds the relative paths. An external script is still served asscripts/<name>.js, and guests receive the kernel and the non-owner feature files.
API.
- The script API is versioned:
viewer.apiVersionis 1. - The first
loadnow waits for the activated scripts to register. Before, a script that registered late missed it.
Checks.
jsconfig.jsonandtypings/web_runtime.d.tsturn ontsc --checkJsover the kernel and features, throughm3trik/scripts/check_js_types.py, which CI now runs.[tool.m3trik.layers]ranksfeatures/*as peers overkernel.
Docs:
docs/webxr_preview.md, The runtime's anatomy. Tests: the browser suites (viewer 93, server and playblast 291, articulated and shadow web 52) andTestShadowModelConformance. - The built-in scripts moved from
-
2026-10-03 --
UvTransfer.layout_jobs(parts, sources, log=None): a texture transfer's outputs are grouped into layouts by overlap, never by UV set name; a normal map's convention is read off its content first (geo_utils/uv/transfer.py). The hosts bucketed each target by its UV set's NAME beforemerge_layoutscould look at overlap, so a production table whose parts came in through Maya (map1) and an FBX (UVChannel_1) -- one combined layout -- was transferred into two materials.layout_jobstakes what each target contributes per (target material, target UV set), faces that wear nothing included (material: None), and merges every group in ONE overlap test: one job named after the set(s) when nothing overlaps, one per material otherwise. mayatk and blendertk call it instead of their twin bucketing loops.UvTransfer.normal_convention(path)now reads the map's content before its filename, through the newImgUtils.detect_normal_map_format-- the integrability statistic's body, moved down fromMapFactory(whosedetect_normal_map_formatdelegates to it, unchanged) becausegeo_utilsranks below the texture engines and image analysis belongs inimg_utils(img_utils/_channels.py): the remap needs the RELATIVE handedness of X and Y -- which way an island's rotation turns them -- and that is what the integrability statistic measures; an untagged_NormalDirectX map rotated the wrong way. The filename decides where the content cannot (flat, shallow, not a file).transfer_materialsreads each source map's own convention and converts a source in the other one (green flipped) to the convention covering most of the layout, so a consolidation is one map in one convention. The docs say outright what the normal step is: the same resample, plus the XY turn a rotated or mirrored island needs, read off the two UV layouts alone -- not a bake, and indifferent to where the geometry stands. A source map a material names but the disk lacks is said (<layout>: WARNING <channel>: <file> not on disk) rather than dropped quietly -- a production tray material named its emission map by a name the file no longer had, and the channel vanished from the result without a line. Texels a source normal map holds that are no tangent-space normal (Z below zero -- a black, unpainted background under some of a production table's faces) still transfer as they are, and the map that holds them is named: turned 180 degrees with their island they came out as a yellow strip that read as a transfer bug. Tests:TestLayoutJobs,TestTransferMaterialsNormals,TestMissingSourceMaps,TestInvalidSourceNormals,TestNormalConvention.test_the_content_outranks_the_filename/test_an_untagged_map_reads_its_content/test_a_flat_map_falls_back_to_its_filename,test_img.pyDetectNormalMapFormatTest. -
2026-10-03 --
FileUtils.move_fileoverwrites a destination near the name or path limit (file_utils/_file_utils.py). An overwrite stages the source beside the destination under.<name>.<pid>.<uuid>.moving-- the destination's name plus 23 characters -- so a destination near a limit (a name's 255 characters, or a path's 260 in a process that is not long-path aware, such as Maya) staged where no file can exist, and every overwrite into it failed: a soldering table's lightmap atlas, 238 characters under its texture folder, was never placed and its bake was lost. The stage is now a fixed-length.<uuid>.moving, asatomic_write_text's temp already was. Test:test_a_destination_near_the_name_limit_still_takes_an_overwrite. -
2026-10-03 --
ImgUtils.convert_scene_linear(image, src, dst="scene-linear Rec.709-sRGB", bgr=False): linear light between colour-space primaries (img_utils/_color_space.py). Renders made in a wide-gamut working space -- Maya renders in ACEScg by default, and so does whatarnoldRenderToTexturewrites -- were read as linear Rec.709 by every consumer: an ACEScg pure red reads as (0.61, 0.07, 0.02), and a production room's lightmap shipped its lighting tint ~30% under-saturated. The spaces are Maya's default OCIO config's scene-linear ones (ACEScg, ACES2065-1, Rec.709-sRGB, DCI-P3 D65, Rec.2020) by its own matrices to ACES2065-1, also under the ACES / OCIO v2 / Blender names; a log or display encoding raisesKeyError. -
2026-10-03 -- The web lightmap encode is stochastic-rounded, not rounded to nearest:
ImgUtils.quantize_8bit(unit)(img_utils/_color_space.py,ImgUtils.encode_hdr_for_web). Rounding every texel of a smooth wall to its nearest 8-bit sRGB code terraced a production room's gradients into contour bands 0.5-1% apart in the WebXR preview.floor(v * 255 + r), one seeded draw per texel for all its channels: unbiased, colourless grain of at most half a code, a value already on a code stays exact (the cross-implementation golden constant still encodes to 255), and the same map always encodes to the same bytes. blendertk'sencode_for_webtwin calls it too. -
2026-10-03 -- The WebXR viewer's ground grid never draws over the model (
net_utils/preview/viewer.html). It lay in the plane a room's floor sits in and, transparent, drew after the model and depth-tested against it, so it z-fought through every such floor: a dark 0.5 m grid, dashed at a distance, over a production room's floor. It now draws first of everything, without writing depth, with its translucency kept by explicit blending -- the reference for where there is no model, covered wherever there is one. -
2026-10-02 --
ShareTunnel: Tailscale shares run beside each other, one per HTTPS port (net_utils/share_tunnel.py). A second DCC sharing through Funnel failed with "exited (code 1) before it was ready": the machine's one*.ts.netname holds one listener per port, and a second foreground share on 443 is refused (listener already exists for port 443, measured on 1.102.2, also against aserve --bgof the user's own). A provider entry may now declarehttps_ports(formatted intoargsas{https_port}) andport_taken; a start whose client exits printing that refusal moves to the next port, and only that refusal moves it. Both Tailscale entries take 443, 8443, 10000 -- the three Funnel accepts -- so the second share's link ishttps://<machine>.<tailnet>.ts.net:8443. The first keeps the bare name, and a fourth fails naming the limit and cloudflared, which has none. Tailscale forwards the Host with its port, which is the spellingPreviewServer.sharealready admits (verified through a livetailscale serve). -
2026-10-02 --
PreviewServer.describe_scene()/GET /scene.json: the published scene as data, for a reader that cannot run the page (net_utils/preview/_scene_description.py,routes.py). It returns the GLB's own JSON chunk verbatim (an embedded base64data:payload is cut to its size) and the lighting recipe the viewer renders it with (extras.scene_sidecar.handoff.rendering, found where the viewer'sreadExtraslooks; elseMeshConvert.RENDERING_POLICY, flaggedrenderingSource), plus the three.js release the served page imports. It is sized for an agent's context: the overview inlines the smallest sections up toSCENE_INLINE_BYTES(128 KiB) and indexes every one, and?section=<key>&start=<n>pages a section within the same budget. A 3.5 MB production GLB gives a 71 KB overview, with its nodes in five pages. The guest listener serves it too, since it is the JSON of the asset a guest can already download. New:SCENE_PATH,PreviewServer.scene_url,share_info()["scene_url"](always on the tunnel, never the alias), and a<link rel="alternate">inviewer.html's head. -
2026-10-02 --
ImgUtils.resize_into_cell,ImgUtils.extrapolate_fill,ImgUtils.stitch_seams: atlas cells that meet at a 3D edge read one value there (img_utils/_atlas.py,img_utils/_filters.py).resize_into_cell(image, size, coverage=None, edge_centers=True)is an exact, coverage-weighted area resample that lands the source's edges on the cell's border-texel centers -- the mappinginset_rects_to_texel_centerspublishes (numpy only, float64 prefix sums).extrapolate_fill(image, mask, rings=1, clamp=2.0)grows a mask by continuing its content's slope rather than averaging it.stitch_seams(image, pairs)nudges only the texels a set of bilinear read pairs touch until both reads agree. Built for mayatk's lightmap pack, where they took a production room's panel seams from 5.4% to 0.4% (median). -
2026-10-02 --
ImgUtils.rasterize_uv_trianglesis vectorized (img_utils/_rasterize.py). One scanline pass over every triangle (row spans into a difference array) replaces a per-triangle Python fill: 5000 triangles at 1024 x 4 supersample in 0.5 s against ~13 s, written a band of rows at a time so its scratch stays near the mask's own size; the centre-sample, edge-inclusive rule is unchanged and pinned against the per-triangle reference in the tests. -
2026-10-02 --
UvTransfer.transferkeeps a layout's alpha opaque where a source has none;value_max=andUvTransfer.dominant_source(job)are new (geo_utils/uv/transfer.py). Sources of different channel counts are widened to one layout, and grey was repeated into EVERY channel, alpha included: a metal-0 greyscale map beside a packed RGBA MetallicSmoothness one wrote smoothness 0 over its whole region (measured on a production table: the mat's 58 triangles came out at alpha 0 against 255), and a black albedo beside a cutout one would punch a hole. An RGB source's missing alpha was padded at a GUESSED scale (255 only if the data exceeded 1), so an all-dark 8-bit map was padded at 1/255. Grey now fills the colour channels, a missing alpha is opaque atvalue_max(whichtransfer_materialsandtransfer_normalspass; None keeps the guess for other callers), and grey+alpha keeps its alpha.dominant_sourcenames the source covering the most of a job's TARGET-UV area -- what the host adapters model an assigned material on (assign_from="source"); the per-triangle area it weighs is_auto_size's, hoisted. -
2026-10-02 --
StrUtils.common_name(paths, strip=): a short default name for what a set of hierarchy items is collectively called (str_utils/_str_utils.py). For one item it returns that item's own name. For several it returns the leading name tokens they all share (chair_leg+chair_seat->chair). Failing that, it returns the deepest group holding them all (...|SOLDERING_TABLE_LOC|TABLE+...|SOLDERING_TABLE_LOC|MAT->SOLDERING_TABLE_LOC), and failing that, the first item's name. Each candidate drops one strip affix (passNamingConvention.all_affixes()and_LOCcomes off) and any namespace. The result keeps only name-legal characters and is capped atmax_length(32) on a token boundary. tentacle's Transfer tool names its output with it when no name is typed. Tests:test_str.pyCommonNameTest(6). -
2026-10-02 --
UvTransfer.concatenation_order(target, parts)andUvTransfer.find_combined(face_counts, read): read which meshes a combined mesh was built from (geo_utils/uv/transfer.py). Maya's Combine and Blender's Join both append each part's faces after the previous part's and offset its vertex indices (measured on Maya 2025 and Blender 5.1, in either order).concatenation_ordertakes(counts, verts, points)per mesh and returns the parts, in order, that join exactly into the target's topology, orNone. Parts with identical topology are told apart by position (POSITION_TOLERANCE, 1e-4). A part that matches only by topology is still accepted when nothing coincides. The search is iterative and filters candidates by their first point, so 1,200 identical pieces resolve in 0.13 s.find_combinedpicks out the one mesh made from all the others (three or more meshes). It takes face counts plus aread(i)callback, and reads a mesh's full topology only when some mesh's face count equals the rest's total, so a selection with nothing combined costs only the counts. This is the DCC-agnostic half of mayatk's and blendertk'sTextureTransfer.pair_sources/find_combined. Tests:TestConcatenationOrder(10). -
2026-10-02 --
UvTransfer.resample_lightmaps(jobs, output_dir=, read=, write=),UvTransfer.remap_lightmap(image, src_tris, dst_tris, scale_offset=)andUvTransfer.layouts_match(src_tris, dst_tris): carry lightmaps into other lightmap layouts (geo_utils/uv/transfer.py).remap_lightmapreads one object's map through its atlas rect (uv * scale + offset, V up). Only the object's own island counts as source, so a neighbour's lighting across the cell edge is never sampled, and the read is cropped to that island, so an atlas shared by dozens of objects is not filled whole once per object. Values are HDR and never clipped. The default size is the texels the object owned in its source map, rounded up to a power of two and never larger than that map.resample_lightmapsis the half mayatk's and blendertk'sLightmapRecords.transfer_lightmapsshare, astransfer_materialsis fortransfer. It skips a job whose two layouts match (layouts_match, corner drift withinLAYOUT_TOLERANCE, 1e-5), because the host rebinds those. It names the rest<output_name>_Lightmap(one map) or<output_name>_<name>_Lightmap(several), never onto a file another object reads or a source map, and reads each shared map once. The host supplies the EXR reader and writer. -
2026-10-02 --
RpcPlugin's server no longer prints a traceback when a client drops its connection (net_utils/rpc/plugin_core.py). A client that reset its connection mid-request, or one that a ping timeout abandoned, made Python's defaulthandle_errorprint the wholesocketservertraceback (ConnectionResetError: [WinError 10054]) into the host's log. Painter showed one each time._ReusableServer.handle_errornow drops connection-reset/aborted/broken-pipe errors, the same filterpreview/routes.py's server applies; any other error still reports. The 4 staged_rpc_core.pycopies have been re-synced. Tests:test_plugin_coretest_a_peer_reset_is_not_reported_as_an_error(a real RST; fails before the fix),TestDisconnectPolicy(both servers: a dropped connection is silent, any other error is still reported). -
2026-10-01 --
ImgUtils.crop_to_uv_bbox(img, bbox, cell)andImgUtils.uv_crop_extent(bbox): the lightmap atlas's island crop, shared by both bakers (img_utils/_atlas.py). A baked map whose UV islands cover only part of the unwrap is cropped to the texels the island touches (no pad), and the crop is folded into the published rect souv * scale + offsetstill lands where the texels went.uv_crop_extentis the fraction the crop keeps, which a baker divides a tile's planned size by. Moved from mayatk's privateLightmapBaker._crop_to_island, so blendertk's twin gets the same rule rather than a copy. Tests:test_img.py::CropToUvBboxTest(3; the edge-extension test moved from mayatk). -
2026-10-01 --
UvTransfer.layout_overlaps(uv_tris, points): whether a UV layout can be lightmapped (geo_utils/uv/transfer.py). It returns(overlapping texels, covered texels): two different surface points on one texel. The layout is rasterized bybuildwith each corner's normalized position as the key, so triangles sharing an edge agree along it and never count, while stacked or mirrored islands do. Two fixed projections are used, so no single direction hides an overlap. This is the DCC-agnostic core of blendertk'sUvDiagnostics.is_bakeable_lightmap; mayatk asks Maya'spolyUVOverlap. Tests:TestLayoutOverlaps(3). -
2026-10-01 -- Shot Manifest templates expose their own options, and one built-in
defaultcovers whatspeedrunand the interactive-training layout needed (core_utils/engines/shots/manifest/). A template'soptionsblock declares settings --boolorchoice, each value a partial template merged over it -- that a UI renders as widgets:Mapping.option_specs(shaped for uitk'sAttributeSpec),Mapping.apply_options,Mapping.resolve(options=);MappingSpecvalidates them, and every shipped option value is tested to yield a valid template.defaultoffers Step IDs (classic= the oldA01.)/ SETUP rules, the default;numbered=A01/IN_01/B03.5with INTRO/OUTRO banners), Audio (derive= whatspeedrundid -- that file is retired) and Auto-fill Missing Assets. The row grammar behind it is data:ColumnMap.step_pattern/section_pattern(defaults are the old hard-coded rules; the private_STEP_RE/_ALT_STEP_RE/_SECTION_REare gone). Step IDs are spelled as legal shot names (B03.5->B03_5); an asset cell lists one object per line;defaultpasses Step Name, Hint, Text On Placard, FBX/GLB Names and Audio Files into shot metadata; "fade(s) away" reads asfade_out. -
2026-10-01 -- Shot Manifest: a sheet that names its objects only in prose lists them (
core_utils/engines/shots/manifest/,str_utils/). A behavior template'sdetectphrase may capture a named groupobject(Behaviors.subjects->[(subject, behavior)]); the shippedhighlight,fade_inandfade_outphrases read "Highlight Red Door" and "Destination circle fades in". WithColumnMap.object_source = column_else_description(the default template's new Objects option, on by default) a step whose asset cells are empty lists those objects, each with its behavior, merged per step whatever the casing;ColumnMap.object_case(aStrUtils.set_casecase orkeep) andobject_name_rule(aStrUtils.apply_name_rulerule orkeep) shape the names. A choice option may name a schemafieldinstead of listingchoices, offering that field's allowed values, so the template's case and name pickers are exactlyStrUtils.CASES/StrUtils.NAME_RULES.BuilderObject.origin(column/description/shot) replaces today'sgeneratedflag. NewStrUtils.CASES,StrUtils.NAME_RULES,StrUtils.apply_name_rule. Fixed: a sheet with no header row logged the behavior source instead of the file's name. -
2026-10-01 -- Shot Manifest reconciliation: one pairing, one identity rule, owned behavior keys, and statuses that say what Build will and will not fix (
core_utils/engines/shots/). Pairing --ShotManifest.pair(steps)(-> newShotPairing) is the one place a step finds its shot: the binding a build now writes inmetadata["step"](so a renamed shot stays paired and an inserted step shifts nothing), then the name, then -- with the template'smatch: name_then_order-- timeline order; a shot nothing pairs with is an orphan. The planner, Assess and auto-fill all use it. Identity --ShotStore.member_key(leaf, namespace dropped) andShotStore.resolve_member(found/missing/ambiguous; the DCC stores look names up, namespaces included) replace the ad-hoc comparisons; fixed: the planner diffed stored long paths against the doc's short names, so an animated object removed from the doc stayed in its shot. No bulk removal by default in the panels, Assess writes nothing (it used to merge discovered objects into shots), and a build keeps metadata keys it does not own (MANIFEST_METADATA; it replaced the whole dict). A doc object removed from its shot by hand is put back by the next build. Ownership --ShotEditLedgergains anauthoredregister (record_authored,owns_authored,owns_any,authored,release_authored; moves, cuts,disown_shotand serialisation cover it; older scenes load with it empty): Build records the keys the behavior applier reports writing,release_authoredtakes them out before a re-apply, andunowned_keysis the animator's keys on a behavior's own channels.reapply_objectis the shared per-object Apply. Statuses --ambiguous_object,not_in_shot,behavior_conflict,unknown_behavior,no_objects,not_in_doc(palette aliases;StepStatus.ROLLUP/HELP). Behaviors -- a template'sdetectphrases name it in prose and in a behaviors column (Behaviors.detect,Behaviors.from_cell; the hard-coded_BEHAVIOR_PATTERNSare gone); newhighlighttemplate;ColumnMap.behaviors+behavior_source(descriptiondefault /column/column_else_description). Auto-fill reads only a paired shot: its existing members plus what animates in its range, asset-like ones only (_is_asset_candidate); a step without a shot stays empty -- its range would be a guess.BuilderObject.origin,BuilderStep.asset_cell,ManifestModel.asset_column/column_clipboardwrite the generated names back as a paste-ready column; asset cells list one object per line.speedrunresolves todefault+{audio: derive}with a deprecation notice (remove_in=0.14.0). -
2026-10-01 --
BuilderStep.from_shots: a store's shots back as manifest steps (core_utils/engines/shots/manifest/manifest_model.py). The inverse of the build's step metadata: a manifest-built shot gets its CSV objects, behaviors, section and voice text back (members discovered after the build stay additional); any other shot keeps its description and members. Feeds the manifest's no-sheet mode. -
2026-10-01 --
RemoteFilenames the real cause of two fetch failures (net_utils/remote_file.py). WinError 10013 (the OS refusing the socket -- an outbound firewall rule) now names the blocked program and says to allow it, instead of "check the connection"; an HTTP 401/403 from a Google Sheet adds the share-the-sheet remedy. -
2026-10-01 -- A baked material's reflections follow its bake, and play at full level by default:
lightmappedMaterials.reflectionNormalization,envMapIntensity0.25 -> 1.0 (net_utils/preview/viewer.html,file_utils/mesh_convert/_sidecar.py). Reported as weak specular on a production table's baked objects. Measured through the real page on its 09-30 export: one flat level for every baked texel had to sit at a quarter, because the studio environment is far brighter than the baked room and full strength lifted every shadow (0.06 -> 0.22 of display). At a quarter, the lit plastic, metal and wood showed no visible reflection. Each texel's reflection is now scaled by its lightmap's luma over the environment's own irradiance about the surface normal, clamped to 1: lightmap-normalized reflections, the reflection-probe normalization a runtime applies to a lightmapped surface. A shadow reflects next to nothing, so the full level lifts none. On the production table at full level, compared with the old quarter: foot pedal 44 -> 79, mat 97 -> 118, wood top 115 -> 124, the shadowed under-shelf 82.5 -> 80.9, and the whole frame 131 -> 134 (no wash-out). The rule is stated in the recipe, as the relief is, so a recipient runtime applies it too; a GLB that publishes its own level keeps it. Tests:test_preview_viewer_livetest_a_baked_reflection_follows_the_bake(a glossy metal under a lit and a shadowed bake at one level; red against the previous page), andtest_preview_serverpins the rule's presence in the page and the recipe. -
2026-10-01 --
UvTransfer.output_labels: a named texture transfer re-run over several layouts no longer stacks its name (geo_utils/uv/transfer.py). A named run calls each output<name>_<layout>, and a layout is labelled by its TARGET material: on a re-run, that is the material the previous run assigned. Each run prepended the name again. A production table reachedSolderingTable_SolderingTable_SolderingTable_SolderingTable_TABLE_ASSETS_MATafter four runs, on the maps, the material and the lightmaps the baker then named after that texture set.output_labels(labels, base, prefix=, suffix=)strips every leading<base>_plus the material's own affix, so the first run and every re-run write the same files and the same material. A label that would strip to nothing, or onto another layout's label, keeps its own spelling, so two layouts never share a stem. The host twins call it (mayatk and blendertkTextureTransfer.transfer). Tests:test_uv_transferTestOutputLabels(+7). -
2026-09-28 --
FileUtils.get_classes_from_pathandModuleAttributeResolverread a source file saved with a UTF-8 BOM (file_utils/_file_utils.py,core_utils/module_resolver.py). Both opened source files asutf-8, which keeps the BOM as a U+FEFF character, andast.parserejects it. Python itself imports such a file without complaint, and Windows PowerShell 5.1 writes one by default. So a single BOM file in a scanned tree raisedSyntaxErrorout of the scan and took down theSwitchboardthat ran it: three of uitk'stest_shortcutstests errored this way during a full-suite run. The resolver caught the error and returnedNone, which silently hid that module's classes. Both now readutf-8-sig, which drops a leading BOM and is otherwise the same asutf-8. Tests:test_filetest_a_file_saved_with_a_bom_is_scannedandtest_module_resolvertest_a_module_saved_with_a_bom_is_parsed, both red before the fix.