github m3trik/pythontk v0.13.0
pythontk v0.13.0

5 hours ago
  • 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 reads OPENCV_IO_ENABLE_OPENEXR once, 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, as ImgUtils does. 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_channels skipped every declared take take_curves did 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_curves keeps 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 -- and take_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 labelled name#uid and a multi-layer channel is unioned; on the production file this recovers 45 channels in Take 001 and 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 ValueError wherever it is cut (fbx_file.py, _fbx2gltf.py, glb/pipeline.py). A cut inside a zlib key array raised zlib.error (elsewhere struct.error) past every (OSError, ValueError) catch -- out of conversion_timeout before a conversion was even attempted -- and a cut where nothing is decoded parsed silently, so check_fbx_container PASSed it. Both now raise ValueError("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_spans wrote through overlay_data_export, which clears the channel from every carrier, so each conversion dropped a referenced module's visibility_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_glb parses 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_timeout and bake_node_frames take fbx=. Test: test_the_input_is_parsed_once_for_the_budget_and_the_clip_spans.

  • 2026-10-04 -- check_clip_origin judges 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_file overwrites on one volume with one rename (file_utils/_file_utils.py). The 20-character .<hex>.moving stage 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 one replace_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 through mimetypes, which on Windows reads HKCR\.js\Content Type; an installer that left that at text/plain blanked the whole viewer, because a browser will not run a module script typed as anything but JavaScript. _PreviewHandler.extensions_map now names .js/.mjs, .json, .wasm and .glb itself, on the owner's and the guest's listener; the server warns when an install carries the page without its kernel/, and the release workflow's installed-wheel check requires kernel/main.js and features/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 prints environment.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.py is green again (the articulated-rig _held return is typed) and a new js-types job 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); stale preview/scripts/ paths and "does not await" claims from the features/ 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_shot dropped only the record, and define_shot numbers a new shot max(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_shot now disowns the claims: the keys stay in the scene, owned by no shot, and undo gives them back. remove_stale_shots drops behavior claims as well as bound samples, ShotEditLedger.record_authored takes over a claim no shot owns on its frame, ShotTransfer.merge lands 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 by ShotStore.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_fps left 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_object resolved 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.
  • 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.002 now gives Cube (was Cube_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_image and assemble_atlas (img_utils/_filters.py, _atlas.py). Each worked in float and cast back with a bare astype: 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 four test_an_8bit_* cases.

  • 2026-10-04 -- UvTransfer.resample_lightmaps reads 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_linear knows 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.2020 and the lin_*_scene names raised KeyError. 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>.png unchecked, so a re-run overwrote a kept source material's files beyond recovery. Names now go through FileUtils.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_spans is 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 published clip_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_tracks cleared the record with no tracks. The rebuild declined after the converter's shot takes had been dropped, and the GLB shipped Take 001 alone (clips_vs_takes FAIL).
    • 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_glb reads its input's FbxFile.take_spans() (each take's first and last key, in seconds) and stamps them onto the GLB's clip_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_origin is the one reader of the published value (apply_glb_clips, ExportVerifier.check_clip_origin).
    • The converter's input. GlbPipeline._drop_split_takes drops 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_frames counts each take's measured key extent, falling back to the declared LocalTime. 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, five TestApplyGlbClips measured-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.
  • 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_SECOND is 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_arrays also accepts record names now, inflating only those.
    • ExportVerifier.check_fbx_take_channels checks 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 in with_tasks. mayatk predicts the whole-timeline take's start from it before the range is set.
    • Tests: test_export_verify TestFbxTakeChannels (5), plus TestFbxFile'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.js and environment.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) with setRows, addButton, addSlider, addToggle and remove; show, toggle, onShow, setBadge. addToggle is on addPanel too. addButton and addPanel still 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.probe and specs.load.probeMs carry 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_live test_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.
  • 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-following reconcile_all_shots. Tests: test_shots_core TestEditLedgerRenameCurve.

  • 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 answers CHECK_OVERRIDE, CHECK_OVERRIDE_ALL or CHECK_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_tasks is True when every failure was overridden; _last_overridden_checks names them.
    • SceneExporterBase.decide_check_failure(check, messages, remaining) is the seam a panel overrides with a dialog. Headless, it asks confirm about this one check. check_failure_html builds the dialog body (the failure, what it reported, the checks still to run, what was already overridden, what each button does), and check_label names a check by its panel row. _run_task_pipeline runs 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 by decide_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.py new, 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.EffectRecipe is 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. window gives where a template places them, envelope the effect's shape for readers that do not key, and fingerprint(effect) the stamp its keys carry.

    • ShotStore.effect_recipe holds the recipe with the scene, and ShotTransfer carries 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 an effect and a place instead of spelling out keys, and BehaviorSpec validates both. Behaviors.keyed(behavior, recipe, fps) gives every reader the template's envelope. A custom template keeps its attributes.

    • Behaviors.apply_to_shots is 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_build says when a Build would change a step. Build re-keys a stale behavior. ShotManifest.release_dropped, run by sync, deletes the keys of a dropped behavior; locked shots, and shots no step pairs with, keep theirs. reapply_object gives back the keys of behaviors its object no longer lists.

    • ShotManifest.adopt_placed_clips claims 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.

    • ShotTransfer carries 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 class OWNERS names for a record, or None. 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 rewrote prefs.json in 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_core test_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.js new, 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_lightmaps embeds a manifest probe -- the host bake's HDR of the room, its capture point and projection box in scene units beside unit_scale -- as Radiance bytes in a bufferView, published as extras.lightmap_web.probe in metres (_embed_lightmap_probe; new ImgUtils.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, at environment.probeIntensity 1/pi -- the lightmaps' unit, a white card's radiance three.js reads as irradiance. Its reflections play whole: lightmappedMaterials.envMapIntensity is the studio's level. specs.lightmaps.probe and 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; the lightmap_metadata description names it. MathUtils.metres_per_unit reads the one length table, and RigVerify.UNIT_METRES now 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_live test_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_math test_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_prefs rewrites prefs.json on every save with a plain write_text; cut off mid-write, it left a truncated file the reader discards, and the preferences with it. It now goes through UserConfig.save_file.

  • 2026-10-03 -- A manifest highlight beside another behavior keeps its ramps; Behaviors.anchor_overrides is 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. A highlight has 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 a fade_in listed after it landed mid-shot. anchor_overrides is now the one placement rule: point behaviors (one phase) spread in doc order, a span keeps its own anchors. ShotManifest.reapply_object and assess use it, as do both hosts' builds. reapply_object also 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.py new). New API:

    • SchemaSpec.TYPED: a TYPED = True spec checks each field against its type hints -- Literal, Optional, Tuple, List of 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 with nested=.
    • WebProjection(key, version, shape): a record's web manifest key, version and shape, held by the new RecordSpec.web field (beside RecordSpec.shape, the payload's own shape).
    • SceneRecords.shape(), web_shape() and web_projected().
    • ArticulationRecord and ArticulationWeb.

    The articulation payload is the pilot. ArticulationRecord declares what both DCC producers write. ArticulationWeb is its WebXR projection, with node indices.

    PreviewServer.AUTO_SCRIPTS and the MeshConvert mixins (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.py generates, 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; ShadowConformance is new (core_utils/conformance.py new, geo_utils/shadow_projection.py). ptk.Conformance.cases(name, seed=0, **options) returns a model's cases by name. names() and provider() 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 in TOLERANCE were 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 and features/ holds the scripts, both served as written (net_utils/preview/). viewer.html keeps its markup, CSS and import map, and loads kernel/main.js, the composition root. Its former inline module is now one kernel module per part, plus the shared vector/quaternion kernel/math.js and the generated kernel/records.js.

    Layout.

    • The built-in scripts moved from scripts/ to features/. The shadow rig and the articulated rig are folders, each with a host-free model.js beside its three.js entry.
    • The server mirrors that layout: PreviewServer.SCRIPTS holds the relative paths. An external script is still served as scripts/<name>.js, and guests receive the kernel and the non-owner feature files.

    API.

    • The script API is versioned: viewer.apiVersion is 1.
    • The first load now waits for the activated scripts to register. Before, a script that registered late missed it.

    Checks.

    • jsconfig.json and typings/web_runtime.d.ts turn on tsc --checkJs over the kernel and features, through m3trik/scripts/check_js_types.py, which CI now runs.
    • [tool.m3trik.layers] ranks features/* as peers over kernel.

    Docs: docs/webxr_preview.md, The runtime's anatomy. Tests: the browser suites (viewer 93, server and playblast 291, articulated and shadow web 52) and TestShadowModelConformance.

  • 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 before merge_layouts could 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_jobs takes 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 new ImgUtils.detect_normal_map_format -- the integrability statistic's body, moved down from MapFactory (whose detect_normal_map_format delegates to it, unchanged) because geo_utils ranks below the texture engines and image analysis belongs in img_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 _Normal DirectX map rotated the wrong way. The filename decides where the content cannot (flat, shallow, not a file). transfer_materials reads 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.py DetectNormalMapFormatTest.

  • 2026-10-03 -- FileUtils.move_file overwrites 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, as atomic_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 what arnoldRenderToTexture writes -- 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 raises KeyError.

  • 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's encode_for_web twin 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.net name 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 a serve --bg of the user's own). A provider entry may now declare https_ports (formatted into args as {https_port}) and port_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 is https://<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 spelling PreviewServer.share already admits (verified through a live tailscale 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 base64 data: payload is cut to its size) and the lighting recipe the viewer renders it with (extras.scene_sidecar.handoff.rendering, found where the viewer's readExtras looks; else MeshConvert.RENDERING_POLICY, flagged renderingSource), plus the three.js release the served page imports. It is sized for an agent's context: the overview inlines the smallest sections up to SCENE_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"> in viewer.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 mapping inset_rects_to_texel_centers publishes (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_triangles is 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.transfer keeps a layout's alpha opaque where a source has none; value_max= and UvTransfer.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 at value_max (which transfer_materials and transfer_normals pass; None keeps the guess for other callers), and grey+alpha keeps its alpha. dominant_source names 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 (pass NamingConvention.all_affixes() and _LOC comes off) and any namespace. The result keeps only name-legal characters and is capped at max_length (32) on a token boundary. tentacle's Transfer tool names its output with it when no name is typed. Tests: test_str.py CommonNameTest (6).

  • 2026-10-02 -- UvTransfer.concatenation_order(target, parts) and UvTransfer.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_order takes (counts, verts, points) per mesh and returns the parts, in order, that join exactly into the target's topology, or None. 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_combined picks out the one mesh made from all the others (three or more meshes). It takes face counts plus a read(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's TextureTransfer.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=) and UvTransfer.layouts_match(src_tris, dst_tris): carry lightmaps into other lightmap layouts (geo_utils/uv/transfer.py). remap_lightmap reads 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_lightmaps is the half mayatk's and blendertk's LightmapRecords.transfer_lightmaps share, as transfer_materials is for transfer. It skips a job whose two layouts match (layouts_match, corner drift within LAYOUT_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 default handle_error print the whole socketserver traceback (ConnectionResetError: [WinError 10054]) into the host's log. Painter showed one each time. _ReusableServer.handle_error now drops connection-reset/aborted/broken-pipe errors, the same filter preview/routes.py's server applies; any other error still reports. The 4 staged _rpc_core.py copies have been re-synced. Tests: test_plugin_core test_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) and ImgUtils.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 so uv * scale + offset still lands where the texels went. uv_crop_extent is the fraction the crop keeps, which a baker divides a tile's planned size by. Moved from mayatk's private LightmapBaker._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 by build with 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's UvDiagnostics.is_bakeable_lightmap; mayatk asks Maya's polyUVOverlap. Tests: TestLayoutOverlaps (3).

  • 2026-10-01 -- Shot Manifest templates expose their own options, and one built-in default covers what speedrun and the interactive-training layout needed (core_utils/engines/shots/manifest/). A template's options block declares settings -- bool or choice, each value a partial template merged over it -- that a UI renders as widgets: Mapping.option_specs (shaped for uitk's AttributeSpec), Mapping.apply_options, Mapping.resolve(options=); MappingSpec validates them, and every shipped option value is tested to yield a valid template. default offers Step IDs (classic = the old A01.) / SETUP rules, the default; numbered = A01 / IN_01 / B03.5 with INTRO/OUTRO banners), Audio (derive = what speedrun did -- 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_RE are gone). Step IDs are spelled as legal shot names (B03.5 -> B03_5); an asset cell lists one object per line; default passes Step Name, Hint, Text On Placard, FBX/GLB Names and Audio Files into shot metadata; "fade(s) away" reads as fade_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's detect phrase may capture a named group object (Behaviors.subjects -> [(subject, behavior)]); the shipped highlight, fade_in and fade_out phrases read "Highlight Red Door" and "Destination circle fades in". With ColumnMap.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 (a StrUtils.set_case case or keep) and object_name_rule (a StrUtils.apply_name_rule rule or keep) shape the names. A choice option may name a schema field instead of listing choices, offering that field's allowed values, so the template's case and name pickers are exactly StrUtils.CASES / StrUtils.NAME_RULES. BuilderObject.origin (column / description / shot) replaces today's generated flag. New StrUtils.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) (-> new ShotPairing) is the one place a step finds its shot: the binding a build now writes in metadata["step"] (so a renamed shot stays paired and an inserted step shifts nothing), then the name, then -- with the template's match: 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) and ShotStore.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 -- ShotEditLedger gains an authored register (record_authored, owns_authored, owns_any, authored, release_authored; moves, cuts, disown_shot and serialisation cover it; older scenes load with it empty): Build records the keys the behavior applier reports writing, release_authored takes them out before a re-apply, and unowned_keys is the animator's keys on a behavior's own channels. reapply_object is 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's detect phrases name it in prose and in a behaviors column (Behaviors.detect, Behaviors.from_cell; the hard-coded _BEHAVIOR_PATTERNS are gone); new highlight template; ColumnMap.behaviors + behavior_source (description default / 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_clipboard write the generated names back as a paste-ready column; asset cells list one object per line. speedrun resolves to default + {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 -- RemoteFile names 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, envMapIntensity 0.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_live test_a_baked_reflection_follows_the_bake (a glossy metal under a lit and a shadowed bake at one level; red against the previous page), and test_preview_server pins 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 reached SolderingTable_SolderingTable_SolderingTable_SolderingTable_TABLE_ASSETS_MAT after 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 blendertk TextureTransfer.transfer). Tests: test_uv_transfer TestOutputLabels (+7).

  • 2026-09-28 -- FileUtils.get_classes_from_path and ModuleAttributeResolver read a source file saved with a UTF-8 BOM (file_utils/_file_utils.py, core_utils/module_resolver.py). Both opened source files as utf-8, which keeps the BOM as a U+FEFF character, and ast.parse rejects 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 raised SyntaxError out of the scan and took down the Switchboard that ran it: three of uitk's test_shortcuts tests errored this way during a full-suite run. The resolver caught the error and returned None, which silently hid that module's classes. Both now read utf-8-sig, which drops a leading BOM and is otherwise the same as utf-8. Tests: test_file test_a_file_saved_with_a_bom_is_scanned and test_module_resolver test_a_module_saved_with_a_bom_is_parsed, both red before the fix.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.