github m3trik/pythontk v0.17.0
pythontk v0.17.0

2 hours ago
  • 2026-10-24 -- MaterialFamilies.as_float / as_floats: the raw-value readers the hosts' producers use are public (core_utils/engines/scene_export/material_record.py; file_utils/mesh_convert/_fbx_materials.py). mayatk's MaterialRecords called the private _num / _vec across the package boundary; they are renamed public, pythontk's own callers updated, and the private names are gone (no other caller).
    • Test(s): test_material_record.py +1 test_a_hosts_raw_value_reads_as_floats.
  • 2026-10-24 -- ShadowRecord.RIG_TYPES: a shadow plane's rig types on the root-exposed class (geo_utils/shadow_record.py). A class constant, not a record field; both DCC rigs read it there instead of the submodule.
    • Test(s): test_shadow_record_shape.py +1 test_the_rig_types_are_reachable_from_the_root (red: AttributeError).
  • 2026-10-24 -- A released mayatk / blendertk can push to the WebXR preview again; stale push keywords are no longer swallowed (net_utils/preview/bridge.py). mayatk 0.27.1 and blendertk 0.19.2 call PreviewBridge._attach_sidecar, which this release had removed: every push from them failed ("Failed to produce the export payload"). It is restored, deprecated (remove_in="0.18.0"), and builds the envelope as before. sidecar_summary (re-dated to 0.18.0) now returns materials_summary when no envelope rode the push, instead of "Scene sidecar off". push(texture_format=) (removed) is dropped with one warning naming glb_options (PreviewBridge.RETIRED_PARAMS); push(SCENE_SIDECAR=) is deprecated (0.18.0) and still reaches a released host.
    • Test(s): test_preview_server.py +4 (red first); _SidecarPreviewBridge drives _attach_sidecar as the released hosts do.
  • 2026-10-24 -- WebXR viewer: the whole-timeline clip counts frames from its own start, still shots stay in FULL SEQUENCE, and a retired block is said on load (net_utils/preview/kernel/animation.js, kernel/api.js, kernel/specs.js, kernel/extras.js, features/shadow_rig, features/articulated_rig). viewer.clip.startFrame is the clip's zero_frame, so a 1001-start playblast stamps 1048, not 0047; the readout shows the frame during lead-in. FULL SEQUENCE spans the block's shots, so a shot whose empty clip the build dropped no longer cuts the sequence short and is named in the readout. A file still carrying a retired block (lightmap_web, scene_sidecar, ...) loads with one issue per block ("re-export the file") instead of rendering silently unlit. New viewer.nodeName(gltf, index). The specs.lightmaps.foreign alias is re-dated to 0.18.0.
    • Test(s): test_preview_viewer_live.py +4 (red first: frames from the clip's zero, a still first / last shot the build dropped, a retired block said on load); test_preview_server.py pins the retired-block check in the page.
  • 2026-10-24 -- A lower-case drive letter no longer drops a picked output folder's subdirectories (file_utils/_file_utils.py). FileUtils.convert_to_relative_path compared drives case-sensitively while its guard is_under folds case, so relativize_output_dir(r"o:\proj\sourceimages\lightmaps\hero", r"O:\proj\sourceimages") returned "hero" -- a different folder than the one picked (lightmap and texture-combine output fields retargeted silently on a workspace opened as o:\). The drives are now compared through os.path.normcase, as is_under does.
    • Test(s): test_file.py +1 test_relativize_output_dir_drive_letter_case_is_one_drive (red first; Windows-only).
  • 2026-10-24 -- The expired 0.12.0 API shims are removed; tasks["version"] is re-dated to 0.19.0 (core_utils/engines/scene_export/export_profile.py, net_utils/preview/bridge.py, net_utils/preview/deliverer.py). Public API, removed (warned since 2026-09-23; no caller in any repo passes them): ExportProfile.resolve_output_path(version_format=, timestamp=, name_regex=) -- fold a saved value first with ExportProfile.fold_legacy_naming, as both Scene Exporters already do -- with its "folded" result key and naming_report's retired-inputs warning, which only those inputs could set (both exporters log their own); ExportProfile.naming_report(version_suffix=) (ignored since the baseline went per-scene); PreviewBridge.push(texture_format=) and PreviewDeliverer(texture_format=) -- pass glb_options={"texture_file_type": ...}. Kept and re-dated: ExportRun.from_tasks' tasks["version"] fold now warns until 0.19.0 (was 0.12.0) -- saved profiles may still carry tasks["version"], and saved data gets the longest window (CODE_STANDARD §0). fold_legacy_naming / fold_legacy_regex stay: the exporters migrate saved naming settings through them. file_utils/pending_files.py: an unused hashlib import (ruff F401).
    • Test(s): test_export_profile.py -- the RegEx and Version/Timestamp fold tests go through fold_legacy_naming (the exporters' path) instead of the removed parameters, the report test drops version_suffix, the tasks["version"] test expects 0.19.0; test_preview_server.py -1 (test_the_retired_texture_format_still_names_the_container).
  • 2026-10-23 -- An untracked object's missing map is stale; a missing map no re-bake reaches says so (core_utils/engines/scene_export/bake_baseline.py). Production: Fix All on a room with no baseline left diffuse_cube_LightMap.exr missing, its row promising "Fix All ... bakes it again". BakeBaseline.assess flagged MISSING_MAP only for an object with a baseline entry; an untracked one (no entry, or no record at all) never became a target. It is now flagged tracked or not -- untracked means only the artist can vouch for a map, and one on disk nowhere matches nothing. The map's reader there was a hidden scratch object, which no re-bake renders: StaleReport.map_rows now gives a missing map whose every reader is unbakeable a hint naming what fixes it (show it and re-bake, Revert, or put the file back) naming Fix All's revert. New StaleReport.fix_plan(rows): what a Fix All does so no row is left needing a fix -- combine (the splits no re-bake mends), rebake (the targets), revert (a missing map's readers no re-bake renders) and track (the untracked rest, as they stand) -- the one rule both DCC panels confirm and run (the split pick was a copy in each). The untracked hint names Fix All too.
    • Test(s): test_bake_baseline.py +1 test_an_untracked_missing_map_is_stale (red first), test_map_rows_carry_each_maps_state_for_a_table +the unbakeable missing row (red first), +1 test_fix_all_leaves_no_row_needing_a_fix -- 58/58.
  • 2026-10-23 -- The Bake State says which maps wait for the save (core_utils/engines/scene_export/bake_baseline.py, str_utils/report_doc.py). Production: the Lightmap Baker's Bake State listed two maps in a temp folder outside the project, unexplained -- maps written since the last save, waiting in the session (PendingFiles). A map row may carry saves_to (the file it becomes at the save; its folder that one's): StaleReport.doc shows and opens that folder, marked StaleReport.ON_SAVE ("on save") in italics, and says why under the table; map_rows adds StaleReport.UNSAVED to its hint (the panels' tooltips). ReportDoc.span(..., italic=True).
    • Test(s): test_bake_baseline.py +1 test_a_map_waiting_for_the_save_shows_its_folder_and_why -- with test_report_doc.py, 82/82.
  • 2026-10-23 -- PendingFiles: files written for the open scene wait in the session until it is saved; FileDependencies.superseded (file_utils/pending_files.py, file_utils/file_dependencies.py). An unsaved Combine wrote its atlas over a production room's lightmap and recycled the split-off one: the saved scene reopened with a map missing and three objects lit from the wrong cells. New ptk.PendingFiles (one ledger per process, PendingFiles.session()): a tool writes into a session folder standing in for the home folder, under the file's own name (stage, folder, home, holds), and records that folder; the DCC's save hook runs each tool's on_save(key, hook) through saving(target, saved_from), which tells a hook whether the save forks the scene (a Save As: the first file still reads every home file); a hook moves what the scene still reads home (commit(keep, rename): copied in whole through FileUtils.atomic_write, across drives; a file it cannot write stays staged and is logged) and sets aside what retire held (take_retiring); once every hook has run, the save drops what none kept -- one tool's commit never drops another's. discard drops everything when the scene goes unsaved; the store is a session TempArtifacts, so a crash's leftovers go with the sweep. New FileDependencies.superseded(before, after): remove_superseded's rule, touching nothing -- for a host that sets the files aside later; remove_superseded is it plus set_aside.
    • Test(s): new test_pending_files.py (+10: a staged write leaves the home file, discard leaves nothing, a save keeps/drops, each tool's hook keeps its own, a fork's rename, a held home keeps the file staged, the save tells hooks fork or not (a Save As whose source is gone is no fork), a raising hook never fails the save, retirements handed over once, superseded takes nothing) -- with test_file_dependencies.py, 66/66.
  • 2026-10-23 -- A commit keeps an untracked map untracked; a split is no re-bake reason; StaleReport.tally (core_utils/engines/scene_export/bake_baseline.py). BakeBaseline.advance recorded every marked newcomer as tracked, its map vouched for by a bake of something else: tracking two maps in production turned every other untracked row "up to date". It now records the arrival (so neighbours are told once) with a new entry field untracked, which rematch / assess report until a commit bakes (or tracks) the object. A split is no longer a re-bake target: the DCC bakers combine it with no render, so the same-day unreleased Reason.SPLIT and StaleReport.with_changed are gone; new StaleReport.split_materials (map -> its split group's materials) lets the split hint say whether a duplicate is still in the scene, and every hint names its fix (Fix All, Combine, Start Tracking, Re-Bake). New StaleReport.tally(rows): a footer's line, the rows' states counted worst first. StaleReport.doc's ReportDoc return annotation now resolves (TYPE_CHECKING import).
    • Test(s): test_bake_baseline.py +1 test_a_commit_of_others_leaves_an_untracked_map_untracked, +1 test_tally_counts_the_rows_states_worst_first, the split/untracked hint test rewritten (both material cases), the SPLIT / with_changed tests removed -- 55/55.
  • 2026-10-22 -- The split and untracked map states explain themselves (core_utils/engines/scene_export/bake_baseline.py). StaleReport.map_rows' split hint says what the state is (one material's objects baked across several maps instead of sharing one atlas), how it comes about, what it costs (a texture more, objects that can't batch) and the one-step fix (right-click: Re-Bake -- either map's objects re-pack the whole group). The untracked hint names the DCC bakers' right-click Start Tracking (records the objects as they stand, no render; the next bake does it too) and when a Re-Bake is wiser.
    • Test(s): test_bake_baseline.py unchanged assertions hold (55/55); mayatk test_lightmap_baker.py +1 test_re_baking_either_maps_objects_mends_the_split, blendertk's mirror check.
  • 2026-10-22 -- StaleReport.map_rows: a state names the map's condition, and a hint says how to remedy it; new StaleReport.split (core_utils/engines/scene_export/bake_baseline.py). Reported: "re-packs whole" read as an action, and nothing offered it -- it was what Re-bake Changed would do, not the map's state. The states are now missing (error), split (warn: its atlas group reads several maps, from the new split field {map: every map of its group} a DCC's packing plan fills), N changed (warn), group changed (warn: nothing in it changed, but its group's atlas is rebuilt), untracked (info: baked before change tracking) and up to date (ok). Each row adds hint: what the state means and which button remedies it (Re-bake Changed / Re-bake Selected), carrying the re-pack reason and a Save As copy's writer that the old re-packs whole: why / copied first, then N in place states held. The Bake Report's table reads the same states, and a Needs attention list after it gives each map that is not up to date its hint.
    • Test(s): test_bake_baseline.py test_map_rows_carry_each_maps_state_for_a_table and test_each_map_is_a_row_with_its_state updated, +1 test_a_split_untracked_or_copied_map_says_so_and_how_to_fix_it (red first) -- 55/55.
  • 2026-10-22 -- BakeBaseline.Reason.SPLIT: one atlas group read across several maps (core_utils/engines/scene_export/bake_baseline.py). A material in two shading groups (an FBX import's habit) or a duplicate material baked two atlases where one would do; the DCC lightmap bakers now group atlases by what a group renders, and name the readers of a split's extra maps as changed with this reason ("its material's atlas is split across maps"), so Re-bake Changed packs them into one. Read from the live scene, never recorded. Replaces the same day's unreleased "duplicate" map row, which named the duplicate for a material merge the bakers no longer offer.
    New BakeBaseline.can_bake(participant): the rule assess leaves objects out of the targets by (rendered, not excluded). New StaleReport.with_changed(objects, reason, current): a DCC's own changed objects join a report as assess names its own (reason once, target or unbakeable, out of untracked when it re-bakes) -- the one place both lightmap bakers add their splits.
    • Test(s): test_bake_baseline.py test_a_map_read_through_a_duplicate_material_says_so replaced by test_a_split_atlas_group_is_a_change_with_its_own_words (red first: no SPLIT), +2 test_can_bake_is_the_rule_assess_leaves_out_unbakeable_by, test_with_changed_adds_targets_as_assess_would (red first) -- 54/54.
  • 2026-10-22 -- StaleReport.map_rows gives each map its state for a panel's table, and why says why an object re-bakes (core_utils/engines/scene_export/bake_baseline.py). The Lightmap Baker's Manage page lists the maps a scene reads; map_rows(maps) adds state (up to date, N to re-bake, re-packs whole, copied first, missing), level (a ReportDoc span class) and stale to each row, and doc() reads the same rows, so the table and the Bake Report share one definition. _why is public as why(k, name=None).
    • Test(s): test_bake_baseline.py +1 test_map_rows_carry_each_maps_state_for_a_table (red first) -- 51/51.
  • 2026-10-21 -- A renamed scene's save writes down that it owns what the old name wrote, and a writer on an unreachable drive is not "gone" (file_utils/file_dependencies.py new FileDependencies.writer_gone; core_utils/engines/scene_export/scene_records.py new SceneRecords.adopt_gone_stamps; scene_store.py SceneStoreBase.respell_for_write(saved_from=)). A writer stamp naming a scene file that is gone read as the open scene's own only while nothing was there: a file back at the old name (a Save As back to it, a restored backup) then owned the same maps, and its re-bake wrote over what the renamed scene reads. Every save of the open scene now gives such stamps to the file it was saved from while that is still on disk (after a Save As, the source reads them as its own, so the new file must name it), else to the file written. A copy (an export, an autosave) adopts nothing. written_here no longer calls a writer on a drive or share this machine cannot reach gone: offline is not renamed, and the scene there still reads its maps.
    • Test(s): test_scene_records.py +4 in TestWriterStamp (a save, a Save As, a copy, a Save As into another project), test_file_dependencies.py +1 (an unreachable drive), each red first. 30 affected pythontk modules: 2415 passed, 7 skipped.
  • 2026-10-21 -- The re-bake dry run says what a re-pack drags in, which atlases a copy forks, and which meshes are out of the bake; it renders as a Bake State report; one action:// link names many nodes (core_utils/engines/scene_export/bake_baseline.py StaleReport.joining / forked / out_of_bake, new StaleReport.doc; core_utils/logging_mixin/logger_ext.py LoggerExt._action_url). The footer counted the changed alone ("29 to re-bake") for a bake that re-packed 97 maps; it now adds "(+68 more objects)". doc(maps) is the Bake State breakdown both DCCs show (ReportDoc): a row per map with its folder, readers, size, writer and state, then what re-bakes and why, and what is out of the bake. A list or tuple link parameter repeats its key, so one select link names every reader of a map. A target with no reason (a re-bake of given objects) counts as "requested", not as a neighbour: production PLAYGROUND.ma's dry run read "96 to re-bake: 96 neighbours".
    • Test(s): test_bake_baseline.py +1 TestStaleReport and new TestStaleReportDoc (+4); test_report_doc.py +1 test_a_list_param_repeats_its_key. test_bake_baseline.py +1 TestRequestedReport (red first). 7 affected modules: 256 passed; downstream blendertk 2042 checks and mayatk 2017 tests, 0 failures.
  • 2026-10-21 -- Stale shots ship unless the scene leaves them out; the animation record is DERIVED; a GLB's extras.animation (v2) inherits the record's shots (core_utils/engines/shots/shot_model.py new ShotStore.drop_stale_shots, animation_record.py, scene_export/scene_records.py, file_utils/mesh_convert/_animation.py, _sidecar.py verify_glb, _fbx2gltf.py; the viewer's kernel/animation.js, kernel/parts.js).
    • Stale shots: an export now declares every shot. A stale one (every object it names gone, nothing keyed in its frames) ships as a take that holds still, and the export names it. ShotStore.drop_stale_shots (saved with the store, off by default) leaves stale shots out of the record and the bake range, as every export did before. An authoring publish scans the scene only when it has to leave shots out.
    • The animation record is Kind.DERIVED, so every write refreshes it, hand-offs included. As an authored record, a WebXR or Unity push shipped the last Scene Exporter run's shots or none: measured in a fresh Maya and a fresh Blender, a scene that was never exported pushed no shot at all. The derived audio record had been scoping its events against that stale copy.
    • extras.animation v2: shots is the record's list as it stands, whatever clips the file holds; clips is the glTF binding (name, animation, zero_frame); clip_mode is inherited and default_clip is absent when there is no clip. v1 listed one entry per glTF animation, so a full GLB, a file with no animation, or a cut the build declined lost every shot. A shot with no clip of its own plays its window of the whole-timeline clip. A reader of v1 refuses v2: re-export. verify_glb reports shots.
    • The viewer joins each clip to its shot by name and, on a whole-timeline clip, names the shot under the playhead and captions it.
    • Test(s): test_shots_core.py TestStaleShots reworked (+4: ships by default, drops when asked, the setting round-trips, no scene scan on an authoring publish). test_scene_records.py +1 (a hand-off refreshes the shots, red first). test_mesh_convert.py +2 (a full file states its shots, red first; shots with no animation). test_preview_viewer_live.py +1 (a full file's readout names each shot window, real Edge). Block-shape tests moved to v2. 2941 affected pythontk tests: 2935 passed, 6 skipped. End to end: a fresh Maya 2025 and a fresh Blender 5.1 pushed and exported a three-shot scene (one stale) through the WebXR preview and the Scene Exporter's GLB, and the real viewer in headless Edge listed and named every shot (mayatk/test/temp_tests/webxr_shots_e2e/).
  • 2026-10-21 -- Each private record's description states its payload's top-level shape (core_utils/engines/scene_export/scene_records.py: shot_store, key_stash, smart_bake_sessions, hierarchy_baseline, emissive_registry, render_effects_bindings, lightmap_probe; descriptions only). The generated records table in mayatk's docs/data_nodes.md named these records without saying what they hold. render_effects_bindings was described as a live preview binding; it is now marked as legacy: only the cleanup migration for the retired viewport material mode reads it.
    • Test(s): 2285 affected pythontk tests -- 2279 passed, 6 skipped.
  • 2026-10-21 -- FileUtils.get_classes_from_path parses each file once per version of it (file_utils/_file_utils.py new private FileUtils._class_names_in + _CLASS_NAMES_CACHE). A uitk Switchboard re-scans every slot file on each construction, and the scan re-parsed and fully walked (ast.walk) every file every time: profiled, 95% of uitk's test_switchboard (299 of 315 s) was 22,757 parses of unchanged files. The class names a file defines are now kept per (path, mtime, size, scan mode), so an unchanged file is parsed once per process and an edited one again; returns are unchanged (the import, and with it every class object, still comes from the module itself). Measured: test_switchboard 168 s -> 41 s, test_switchboard_browser 163 s -> 39 s, the next eight slowest uitk modules 562 s -> 112 s, and uitk's whole suite 1358 s -> 439 s (5590 passed, solo, offscreen). A session's FIRST scan is unchanged (BACKLOG 2026-10-11 keeps its decision on the deep-walk contract).
    • Test(s): test_file.py test_an_unchanged_file_is_parsed_once (red first: 3 parses for 3 scans), test_an_edited_file_is_parsed_again; 1586 affected pythontk tests passed.
  • 2026-10-21 -- run_tests.py runs a pointed subset: named modules and -k test-id filters, and a name no module answers to exits 2 (test/run_tests.py; test only). The runner had no way to run less than the whole ~13-minute suite, so verifying one change meant the full run or a bare pytest without the runner's sandbox and coverage tracking. TestRunner(modules=, patterns=) loads the named modules (bare or test_-prefixed) and applies unittest's own -k semantics (TestLoader.testNamePatterns, substring unless the pattern has a *); a scoped run never stamps the badge, -k included (the module gate alone would pass a -k that matches a test in every module). Each status mark is written once: DetailedTestResult wrote its own dot/ok after TextTestResult had already written one (two passes and a skip printed ....ss). The end of a failing run lists the failing test ids instead of repeating every traceback unittest printed above it.
    • Test(s): test_run_tests.py new TestSubsetSelection (5: modules, -k, both, unknown names, one mark per test) -- 44 passed.
  • 2026-10-20 -- The handoff's lighting text states what a reader holding only the file needs to match the approved look, as three cold reads of the files found it lacking (core_utils/engines/scene_export/handoff_record.py, scene_records.py). The probe row now says the approved look takes a lightmap texel as irradiance (three.js r169) and that a renderer compositing texel x albedo (Unity's Standard, URP Lit) matches it with its lightmaps at 1/pi and the probe at probeIntensity -- at 1 the two kept their balance but lit the scene pi times brighter. The shading methods are stated, not named: three.js r169's ACES filmic curve (Hill's fit, exposure / 0.6), PMREM with the studio room's prefilterBlur in radians and the probe unblurred, the split-sum energy terms by their published methods. A GLB lightmap is its texture's RGB through its texCoord and KHR_texture_transform, never glTF's red-only occlusion, and the GLB's materials row says a lightmapped material's occlusion slot is the exception; a highlight copy replaces the probe for diffuse irradiance and reflections alike, and the text no longer names the retired receivers field; the probe reflects at its own level (was "full strength" / "plays whole"); the key light's direction is one, from the origin; the FBX's UVs are V-up, hierarchy keeps namespaces, a probe box's corners are re-sorted after an axis change, a material's inputs are its record's and its lighting the lighting rows'. The shadows text was cold-read twice and rebuilt exactly (placement, and 48 horizon values to 2.4e-7); its readers' ambiguities are stated away (radians, the quad's +X side, the frame's turn, the ground point).
    • Test(s): test_handoff_record.py test_the_probe_level_is_stated_for_both_lightmap_conventions (now pins the 1/pi match).
  • 2026-10-20 -- A deliverable states what its readers read and nothing more: every record and block ships its declared shape alone, its handoff says only what its content uses, and every reader reads the current shape alone (core_utils/schema_spec.py new SchemaSpec.prune; core_utils/engines/scene_export/scene_records.py new Clause, SceneRecords.put_glb_block, RecordSpec.clauses / GlbBlock.clauses and assembly_for, RecordSpec.make prunes; handoff_record.py new HandoffRecord.RENDERING_NEEDS, payloads, rendering_policy gated by held; record_transfer.py; geo_utils/shadow_horizon.py and shadow_horizon.glsl; geo_utils/articulation/model.py; textures/region_masks.py; file_utils/mesh_convert/: _sidecar.py, _fbx2gltf.py, _lightmaps.py, _highlights.py, _shadow_rigs.py, _animation.py, export_verify.py; net_utils/preview/look_capture.py new LookCapture.maps, bound_maps; viewer kernel/specs.js, lightmaps.js, probe.js, relight.js, animation.js, inspect.js, features/shadow_rig, features/articulated_rig; regenerated DELIVERABLE_FORMAT.md and kernel/records.js; docs/webxr_preview.md). RecordSpec.make and the one block writer, SceneRecords.put_glb_block, prune a payload to its declared shape (SchemaSpec.prune) and warn what they cut, each field once -- a writer's drift said, never shipped. The fields no reader read are gone from the shapes and their producers: a shot's members and section; a clip's empty, duration, objects, section and offset (a runtime measures a clip and places it from start_frame / fps); a lightmap entry's intensity (a material's level is the block's), a baked light's name, a highlight copy's receivers and mimeType, the probe's mimeType and name, the block's encoding (sRGB, the one a carrier holds); a horizon map's levels, mapping and encoding (log2 of its size; it has one mapping and one encoding); a GLB shadow plane's names, silhouette, atlas and material (its node and the indices replace the names, a packed plane's tile is its quad's own UVs); an articulation block's joint and grab names (their nodes'); the emissive manifest's resolution (its mask image's own); the handoff's renderer.outputColorSpace. A file's handoff says only what its content uses: a Clause of a record's assembly applies when the payload holds what it is about (a shadows record of projected planes carries no horizon march), and the lighting recipe leaves out each setting the file has nothing for (RENDERING_NEEDS: no probe settings without a probe, no lightmapped-material rule without lightmaps). The GLB build writes its handoff before it knows the file's blocks -- held=None is "not known yet": every row and the whole recipe, never "none" -- and gates it once, last (_finish_glb_handoff), so a lighting choice survives the blocks a later pass adds; the shadows text states the projection, the placement and the horizon march. A finished GLB carries none of the FBX's own account of itself: FBX2glTF's fromFBX notes leave every node and material -- a node's host attributes (MeshConvert.HOST_PROPERTIES: Maya's currentUVSet, 3ds Max's MaxHandle) and the converter's flags; an attribute the artist authored is content and stays -- and each animation's zero_frame note leaves too -- the animation block's clips state it -- with an extras left empty (strip_fbx_records, the last step of fbx_to_glb and finish_glb). Every reader reads the current shape alone: the FBX handoff through the one gate (_current_record; extras.source only), which HandoffRecord.block writes valid (HandoffSource's fields, each a string, an empty one dropped, none without application); RegionMaskManifest.from_dict refuses another version (a ValueError naming both, a schema-keyed v1 included); RecordTransfer reads an incoming record under a former key (renamed_from) at its current one; ArticulationModel builds only from a rig ArticulationRig validates (its problems named; a rig stating no grab has none, as the DCC solvers build theirs), as unitytk's model refuses; apply_glb_lightmaps looks in the search folders and the GLB's own (the record's dir / dirs, which no producer writes, are not read); ExportVerifier places a clip by its block's zero_frame; the viewer names a shadow plane, a joint and a grab by its glTF node, counts an object baked when it wears a material the lightmaps block names (the per-object lightmapInfo read is gone: a finished file carries no marker; specs.lightmaps.foreign, always empty since, is retired: a warning alias, removed in 0.18.0, which bumps API_VERSION), loads every lightmap as sRGB, and labels a clip empty when it has no tracks. A problem line says where, then what (lightmaps.objects[0]: missing required key 'hierarchy'; was lightmaps.objects[0].missing required key 'hierarchy'). Retired (warn; removed in 0.17.0): MeshConvert.read_glb_lightmap_manifest (a finished GLB carries no record: read SceneRecords.glb_block, or MeshConvert.data_export_channel before the strip), ShadowHorizon.record(levels=) and RegionMaskManifest.channels(resolution=) (each ignored), ShadowHorizon.MAPPING / ENCODING, ExportVerifier.check_glb_envelope (check_glb_handoff; a retired gate answers through its successor and is no gate of its own).
    • Fixed on the way: the horizon march's bilinear read clamped its four texels as two pairs, so the half pixel inside the low edge read the next texel; each corner clamps to the tile on its own (clamp-to-edge), in the reference (ShadowHorizon) and the shader alike. A clip's fps derivation from its span (_resolve_clip_fps) went dead with clips[].duration; it measures each clip itself. A relight (LookCapture.relight) read each material's map from the lightmaps block, which has stated none since v2, so it swapped nothing; it reads the image the material binds in the block's carrier slot (LookCapture.maps). The GLB shadow pass looked up -- and embedded, when it found one loose -- a horizon plane's atlas and silhouette, which the march never samples, warning of each one missing ("the plane(s) using it keep their DCC material", of a plane it bound); a horizon plane binds its map alone.
    • Test(s): each fix and new reading red first -- test_handoff_record.py test_a_block_whose_records_are_not_known_yet_keeps_them_whole, test_a_recipe_states_only_the_settings_its_file_uses, test_a_source_is_written_as_its_declared_shape, test_the_payloads_are_each_records_content (was test_the_scope_reads_each_records_payload) (37 passed, 67 subtests); test_mesh_convert.py test_a_lighting_choice_survives_the_blocks_a_build_adds_later, test_a_converted_glb_carries_none_of_the_converters_notes, test_the_converters_notes_leave_a_finished_glb, test_a_handoff_record_of_another_version_is_refused_and_said, test_a_record_names_no_folder_to_look_in, test_a_source_naming_no_application_stamps_this_package_alone, test_the_pass_writes_only_what_its_block_declares, test_the_retired_manifest_reader_reads_the_carriers_record, test_fps_is_derived_when_the_producer_published_none green again (gone: test_a_version_2_records_source_is_read_from_its_top, test_a_version_without_an_application_is_not_dropped) (509 passed, 1 skipped, 102 subtests); test_scene_records.py test_another_scenes_renamed_keys_are_read_at_their_current_keys, test_a_record_ships_what_its_shape_declares_and_warns_what_it_cut, test_put_glb_block_writes_what_glb_block_reads, every problem line where-then-what (148 passed, 247 subtests); test_schema_spec.py new PruneTest (4; 59 passed); test_region_masks.py test_a_document_of_another_version_is_refused, test_the_retired_resolution_warns_and_changes_nothing (44 passed, 7 subtests); test_look_capture.py test_a_materials_map_is_the_image_its_carrier_slot_binds, test_a_current_template_maps_each_material_to_its_bound_lightmap (32 passed, 4 skipped); test_export_verify.py the clip origin read from the block (gone: test_clip_origin_fails_a_file_cut_against_a_span_its_stack_lacks -- a finished file states its clips' origin, never a span of its own to be cut against) (107 passed, 17 subtests); test_articulation.py test_a_rig_its_shape_refuses_is_refused_naming_its_problems (33 passed, 7 subtests); test_shadow_record_shape.py test_a_horizon_plane_looks_up_no_colour_map and the block plane's trimmed shape (17 passed, 78 subtests); test_shadow_horizon.py test_the_retired_horizon_fields_warn_and_change_nothing (31 passed); test_lightmap_record.py test_an_entry_states_what_its_shape_declares_and_no_more (13 passed, 20 subtests); test_lightmap_record_shape.py (14 passed, 46 subtests), test_animation_record_shape.py (12 passed, 30 subtests), test_shots_core.py, test_shot_sequencer.py, test_export_profile.py at the trimmed shapes; the viewer's test_shadow_web.py (39 passed), test_articulated_rig_web.py new test_the_panel_names_each_joint_and_part_by_its_node (22 passed, 1 skipped), test_preview_viewer_live.py (119 passed; test_a_baked_object_whose_lightmap_did_not_bind_is_named read from the block, gone: test_an_object_lit_by_a_bake_it_was_never_in_is_named -- a finished file carries no per-object marker), test_preview_server.py (284 passed, 59 subtests); check_js_types.py OK (every block read typed from records.js). No test but one about it logs a field its shape prunes (a sweep of the whole suite). Full runner (run_tests.py, the live viewer included): 8179 tests, 8137 passed, 42 skipped, none failed; strict pytest, deprecations as errors (the live viewer apart): 8019 passed, 41 skipped, 2038 subtests; m3trik 514 passed, 277 subtests -- its one failure tentacle's (test_push TestAbsorbCommitIsNamed: a file path in tentacle's newest CHANGELOG headline).
  • 2026-10-20 -- A finished FBX states its handoff again last, so its rows say what its records hold after every pass (file_utils/mesh_convert/_mesh_convert.py finish_fbx_deliverable gains a handoff pass; _sidecar.py private _finish_fbx_handoff; fbx_media.py new FbxMedia.records). The block is written at the commit; the highlight copies, a pass after the write, then add the probe's highlights to the lightmaps record -- measured on the soldering export, whose FBX's lightmaps row lacked the copies its record listed. The last pass restates it from the file's records as a reader applies them (FbxMedia.records, every record in one read), its extras.source and the export's lighting choices kept, as _finish_glb_handoff does a GLB's. A handoff on a referenced module's carrier is never read as the file's (FbxMedia.record / records: the scene's own carrier's alone, as the FBX handoff's rules say), so an assembly whose own carrier holds none is left without one rather than given a module's.
    • Test(s): test_mesh_convert.py test_the_handoff_states_what_the_records_hold_after_the_passes, test_fbx_media.py test_records_reads_every_applied_record_in_one_read, test_a_modules_handoff_is_never_the_files (red first).
  • 2026-10-20 -- Every deliverable record declares its shape, a reader checks a file against it, and the standard's reference is generated (core_utils/engines/scene_export/scene_records.py new SceneRecords.problems, block_problems; RecordSpec.version_key retired; EMISSIVE_GROUPS v2, EMISSIVE_REGISTRY key emissive_registry; handoff_record.py new HandoffBlock, HandoffPart, HandoffSource, HandoffExtras; textures/region_masks.py new EmissiveGroupsRecord, EmissiveGroupsEntry, RegionMaskManifest.VERSION, field RegionMaskManifest.resolution removed, unaliased (no reader reads it: the mask image states its size); file_utils/mesh_convert/render_effects_record.py new RenderEffectsRecord, RenderEffectsTrack; core_utils/engines/shots/animation_record.py new AnimationRecord, AnimationShot, AnimationBlock, AnimationBlockClip; audio_utils/audio_record.py new AudioRecord, AudioCue; geo_utils/shadow_record.py new ShadowRecord, ShadowBlock (and their planes, atlases, horizons); lightmap_record.py LightmapRecord (now its shape too), new LightmapsBlock; _shadow_rigs.py MeshConvert.SHADOW_PLANE_DEFAULTS retired; audio_utils/_audio_utils.py AudioUtils.scope_events; _animation.py MeshConvert.ANIMATION_CLIP_MODES (from ExportProfile.ANIMATION_CLIPS_OPTIONS); export_verify.py new gate check_fbx_records; fbx_file.py new FbxFile.user_property_items; _sidecar.py verify_glb; core_utils/schema_spec.py SchemaSpec.to_markdown; new generated core_utils/engines/scene_export/DELIVERABLE_FORMAT.md). A record's fields are declared once, as a typed SchemaSpec beside its producer, and named on its RecordSpec (shape=, GlbBlock.shape=): SceneRecords.problems(item, payload) / block_problems(item, block) say what a payload breaks -- another version, or what the shape refuses -- one line each, and verify_glb checks the handoff and every block with them (its hand-written handoff checks go), the export verifier every record on an FBX's carriers (check_fbx_records; a record-shaped property under a key no record declares fails, an older file's). DELIVERABLE_FORMAT.md -- every record and block, its key, versions, meaning in each file and fields -- is generated from the declarations, the reference handed to an outside reader. emissive_groups states its version as every record does (version, v2; was schema v1), and the registry the Emissive Groups tools keep in the scene takes its own key (emissive_registry, moved once by migrate()): no key names two records, and every record's version rides under version (RecordSpec.version_key, RegionMaskManifest.SCHEMA_VERSION and RegionMaskManifest.schema -- now .version -- warn and are removed in 0.18.0). Every deliverable record and GLB block declares its shape, and the readers' one gate (MeshConvert._current_record) refuses a record that breaks it as one of another version, saying what it breaks -- so a shadow plane's unsaid fields are no longer filled in (SHADOW_PLANE_DEFAULTS warns; a plane's defaults are ShadowPlane's, "" for no source or contact, as the rigs write). A record's lengths are stated as they are for every producer -- times unit_scale, metres -- not as the file's unit. The handoff declares its readers (Unity's importer, the GLB build and runtimes). A key a producer leaves out is typed plainly in a shape; Optional marks a key written null. Fixed: SchemaSpec.to_markdown stated a summary's first line, cutting a wrapped sentence (it states the first paragraph, as json_schema does), gave a spec its annotation alone names -- a nullable object, a map's values -- no table, and headed past Markdown's sixth level. AudioUtils.scope_events shipped an unscoped event before its clip's first frame with a negative frame, which Unity skipped without a word; it is dropped and named, as an event outside every take is.
    • Test(s): test_schema_spec.py test_markdown_states_the_whole_first_paragraph, test_markdown_follows_a_spec_its_annotation_names, test_markdown_headings_stop_at_the_sixth_level (each red first; 55 passed); new test_shadow_record_shape.py (17 passed, 95 subtests), test_lightmap_record_shape.py (14 passed, 51 subtests); test_audio_utils.py test_an_unscoped_event_before_the_origin_is_dropped_and_named (red first); new test_animation_record_shape.py, test_audio_record_shape.py (18 passed, 54 subtests); test_surface_snapshot.py regenerated (624 names); the other shapes' and the gates' tests, counted after the trim above: test_material_record.py (14 passed), test_render_effects_record.py (3 passed, 2 subtests), test_handoff_record.py (37 passed, 67 subtests), test_region_masks.py (44 passed, 7 subtests), test_articulation.py (33 passed, 7 subtests); the gates' -- test_scene_records.py (problems, block_problems: 148 passed, 247 subtests), test_export_verify.py (check_fbx_records: 107 passed, 17 subtests), test_mesh_convert.py (verify_glb, _current_record: 509 passed, 1 skipped, 102 subtests) -- and m3trik's test_sync_scene_records.py (the generated reference, records.js and unitytk's C# types current).
  • 2026-10-20 -- One standard for the data a deliverable carries: every record has one key and one current schema in every file, each thing is stated once, and each is declared once (core_utils/engines/scene_export/scene_records.py: records animation (was shot_metadata), audio (audio_manifest), lightmaps v2 (lightmap_metadata), shadows (shadow_metadata), render_effects (visibility_tracks), handoff v3; new RecordSpec.renamed_from, is_current, RecordSpec.glb / GlbBlock (version, shape, assembly, node_fields), SceneRecords.RETIRED, RETIRED_BLOCKS, glb_records, glb_block, glb_shape; RecordSpec.deprecated_by / remove_in removed, and the RecordSpec(web=, version_key=, deprecated_by=, remove_in=) keywords with them, unaliased (no record is declared outside SceneRecords); scene_store.py new SceneStoreBase.remove, migrate; export_snapshot.py; handoff_record.py new HandoffRecord, HandoffCarrier; lightmap_record.py new LightmapRecord; shots/shot_model.py; export_profile.py; geo_utils/articulation/record.py new ArticulationBlock*; file_utils/mesh_convert/_sidecar.py new MeshConvert.embed_glb_handoff, read_glb_handoff, strip_fbx_records, finish_glb, private _current_record, verify_glb rewritten; _lightmaps.py new MeshConvert.lightmaps_block; _highlights.py, _fbx2gltf.py, _images.py, _textures.py, _shadow_rigs.py, _articulation.py, _animation.py, _visibility.py, glb/edit.py new GlbEdit.extras_of, glb/pipeline.py, fbx_media.py new FbxMedia.drop_redundant_properties, export_verify.py; net_utils/preview/: server.py, _serve_root.py, _scene_description.py, look_capture.py, bridge.py, kernel/extras.js new readBlock, kernel/records.js (generated), kernel/scene.js, lightmaps.js, probe.js, load.js, features/shadow_rig, features/articulated_rig). Every block the pipeline writes -- a record on the FBX's data_export carrier, its block in the GLB's root extras -- is {version, <fundamentals>, extras}, and a record has ONE key in every file: the GLB's concept names, which the FBX's records now take. A scene's stored records move to their new keys once, and a retired one (fbx_takes) is cleared (SceneStoreBase.migrate, run first by every export commit). Every reader -- the GLB build, the WebXR viewer, the Unity importer -- reads the current schema alone and refuses another with one warning (the viewer: one load issue per block, kernel/extras.js staleBlocks -- and one per retired block a file still carries, SceneRecords.RETIRED_BLOCKS: the *_web blocks and the scene_sidecar envelope, generated into records.js as RETIRED_BLOCK_KEYS): a file written before is re-exported, never read through a branch for its shape (gone: the *_web block names, the scene_sidecar envelope reads, the v1 lightmaps block's uv and copies, string-encoded and scene-level blocks, the pre-0.11.0 folder hints, the fbx_takes fallback, the one-release record dual write). A GLB block is named for the file it rides and declared beside its record (RecordSpec.glb), including where it binds by node index (GlbBlock.node_fields), so renumbering, lifting a native export's blocks and naming them in the handoff each loop over the declarations; the viewer's records.js is each block's key and version, generated from them. The handoff is declared once for every carrier (HandoffRecord): the FBX's record and the GLB's extras.handoff (v3) are both HandoffRecord.block(carrier, ...), its lighting recipe values alone (every colour linear). Nothing is said twice: the GLB's lightmaps block states each material's intensity alone; the FBX's lightmap record (LightmapRecord) one unit_scale for every length, its probe box {min, max}, a point light without a direction; the FBX's records and markers leave the GLB's nodes. A host's own exporter finishes its GLB the same way (MeshConvert.finish_glb). Retired (warn; removed in 0.18.0): MeshConvert's constants that restated a record (*_METADATA_KEY / _VERSION, VISIBILITY_TRACKS_*, ARTICULATION_KEY / _VERSION, *_WEB_KEY, ANIMATION_WEB_VERSION, *_WEB_NODE_FIELDS, FBX_TAKES_KEY, LOCATE_HINT_KEY(S) -- read SceneRecords.X instead), without_locate_hints, fix_glb_lightmap_metadata, and the handoff's former names (RENDERING_POLICY, rendering_policy, HANDOFF_INSTRUCTIONS, HANDOFF_ASSEMBLY, FBX_HANDOFF_*, build_fbx_handoff, build_scene_sidecar, apply_scene_sidecar, embed_scene_sidecar, read_scene_sidecar, sidecar_foreign_packings, strip_fbx_handoff, SIDECAR_*, RENDERING_CARRIERS, LIGHTING_ASSEMBLY -- use HandoffRecord); SceneRecords.FBX_TAKES, web_projected, web_shape, rendering_policy, HANDOFF_INSTRUCTIONS, HANDOFF_ASSEMBLY, handoff_block; RecordSpec.web, WebProjection, ArticulationWeb*; GlbPipeline.envelope; PreviewBridge.sidecar_summary.
    • Test(s): counted after the trim above: test_scene_records.py (148 passed, 247 subtests), test_handoff_record.py (37 passed, 67 subtests), test_mesh_convert.py (509 passed, 1 skipped, 102 subtests), test_export_verify.py (107 passed, 17 subtests), test_export_profile.py (76 passed, 76 subtests), test_look_capture.py (32 passed, 4 skipped), test_shots_core.py (325 passed), test_shot_sequencer.py (169 passed), test_fbx_media.py (77 passed), the viewer's test_preview_server.py (284 passed, 59 subtests), test_shadow_web.py (39 passed), test_articulated_rig_web.py (22 passed, 1 skipped) and test_preview_viewer_live.py (119 passed); m3trik's test_sync_scene_records.py. The suite's totals: the entry above.
  • 2026-10-20 -- Every material of a deliverable is stated once, in the file: the materials record (core_utils/engines/scene_export/material_record.py new MaterialFamilies, MaterialRecord, MaterialEntry; scene_records.py new SceneRecords.MATERIALS, RecordSpec.transient, SceneRecords.transients; export_snapshot.py; scene_store.py new SceneStoreBase.discard_if_empty; file_utils/mesh_convert/_fbx_materials.py new MeshConvert.fbx_materials, read_fbx_materials, apply_glb_materials; _fbx2gltf.py; glb/pipeline.py; fbx_file.py new FbxFile.materials; fbx_media.py new FbxMedia.extract). A delivered FBX opened in the WebXR preview showed no emission and no glass and every albedo halved, and Unity read a Phong lens with other metal and roughness than the GLB: each reader translated the file's own materials its own way, and only a live scene's sidecar repaired the converter's reading. The export now states every material in glTF 2.0's terms, produced from the scene as staged for the write (the hosts' MaterialRecords) into a transient record -- carried by the file, never held by the scene: the restore clears it, an authoring publish clears one it finds, and a carrier it alone needed goes with it. The GLB built from an FBX applies the record, reads a material it does not name from the file into the same families (StingrayPBS use-map switches, Standard Surface weights, FBX's legacy models under the named fbx2gltf policy), drops the converter's transcription of it, and gives a metal or roughness map it cannot find its absent value rather than the converter's packing. The FBX and GLB instruction texts are rewritten: shorter, and no longer claiming the file's own materials are complete.
    • Test(s): new test_material_record.py (14); test_mesh_convert.py TestMaterialsRecord (15) and TestMaterialsRecordThroughTheConverter (3, FBX2glTF: the FBX alone, its record, a verifying reader vouching for the ORMs it packed); test_scene_records.py new TestTransientRecords (6); test_fbx_media.py ExtractTestCase, FbxFileMaterialsTestCase.
  • 2026-10-20 -- An absent channel of every packed map stores its map type's absent value -- dielectric, matte, unoccluded, opaque -- never a mirror (core_utils/engines/textures/map_registry.py new MapRegistry.absent_value, absent_fills, and the Mask Map, MRAO, Metallic-Smoothness and Albedo-Transparency backgrounds; map_factory/_channel_packing.py, processor.py, handlers.py; img_utils/_channels.py pack_channels inverts an image, never a fill). Each packer kept fills of its own: an ORM or MRAO without a roughness map, and a Mask Map without a smoothness map, stored a mirror-smooth channel, and an inverted channel's fill came out inverted too. One declaration now -- each map type's default_background, a packed type's pinned to its channels'.
    • Test(s): test_map_factory.py new test_an_absent_channel_stores_its_absent_value_in_every_packing (7 layouts), test_mask_map_alpha_is_the_absent_smoothness_without_one; test_map_registry_register.py new test_a_packed_maps_background_is_its_channels_absent_values, test_absent_values_are_dielectric_matte_unoccluded_and_opaque; test_img.py new test_pack_channels_inverts_an_image_never_a_fill.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.