-
2026-09-07 -- shot planner: a ripple can carry the pivot's trailing gap, and selects downstream shots by ORDER (
core_utils/engines/shots/shot_plan.py).plan_ripple_downstream(..., carry_gap=False)/plan_ripple_upstream(..., carry_gap=False): withcarry_gapthe first moved shot's window opens just pastafter_frame(the pivot's bound, exclusive) so whatever is keyed between the pivot and its neighbour rides too -- a bound change ripples EVERYTHING beyond the bound, the fade tail parked in the gap included -- and the upstream twin caps the last moved shot's window atbefore_frame. Off (the default), the windows are the shots' own envelopes as before, which is what a whole-shot move wants (its envelope already carries its gap). With a real pivot the downstream set is now every shot after it in order rather than every shot starting at/after the frame: a key dragged INSIDE the neighbour grows the pivot past that neighbour's start, and the frame rule left the very shot the drag overran where it was. Pivot -1 (insert/delete) keeps the frame rule.test_shots_core146. -
2026-09-07 -- the shadow canvas keeps a grounded target's feet under the silhouette's feet (
geo_utils/shadow_projection.py,net_utils/preview/scripts/shadow_rig.js). Reported twice as the shadow drifts away from the bottom of the mesh as the light lowers: the canvas stamp pinned the canvas's back edge in footprint radii and the far edge on the projected head, so the drawn feet inside the texture slid forward with the far edge (2.2 units ahead of a 2 m box's feet at a 20 deg source drawn from 60 deg).ShadowModel.rect/ShadowProjection.fractionsnow stamp the near edge as a fraction of the far edge (u0 = u_lo / u_hi), so the anchor keeps its place in the texture at every light height while the far edge still lands on the head; the part behind the feet stretches with the reach instead (it bridges the moves between two renders). The record'scanvaskeeps its shape; every reader -- the Maya expression, the Blender drivers, unitytk'sShadowPlaneController.csand the viewer'splacement-- changed in lockstep.test_shadow_projectionrewrites the attachment tests around the anchor's fraction (25);test_shadow_web30. -
2026-09-05 --
ShotApply.apply(..., objects_for=): the host names what each envelope moves (core_utils/engines/shots/shot_apply.py,shot_plan.py).objects_for(shot_id) -> objectsreplacesshot.objectsas the list handed tomove_keysin all three phases, so a host whose envelopes partition the timeline can pass its whole keyed content and carry everything keyed inside a moving shot without first writing it into the shot's member list -- membership stays a label the host decides on its own terms. mayatk and blendertk do exactly that and drop their membership backfill (measured on a production assembly: the backfill had made forty flat-keyed proxy joints members of all twelve shots).ShotPlanner.objects_to_adopthas no caller left and is marked deprecated for one release. Also: the edit ledger now rides the boundary restore point (shot_model.py,ShotStore._restore_point). A restore put the shot records back and left the ledger's(curve, frame)claims where the edit had moved them, while the DCC's undo moved the keys back -- so after an undo the claims named the animator's own keys as the system's samples, and the next reconcile moved or cut them. Surfaced by the mayatk real-scene harness once every bound change reconciles: one key drag, undone, and the next drag cut 7 of 22 keys offFAILED_CMPT_CASE_LOCcurves. Undo and redo now restore the ledger with the bounds (TestBoundaryLedger.test_the_edit_ledger_rides_the_restore_point);test_shots_core140/140. -
2026-09-05 --
MathUtils.reduce_samplesandMathUtils.evaluate_hermite: a bounded reduce-to-extremes (math_utils/_math_utils.py).reduce_samplesis the whole pass in one call -- the shape-defining keys (IterUtils.find_extrema_indices), their least-squares tangents (fit_hermite_slopes), and a refinement the pair alone lacked: wherever the refit curve misses a sample by more thanmax_error(default 1% of the curve's amplitude), the worst sample of that segment becomes a key and the tangents are fitted again until every segment is inside the bound. One cubic per half-wave holds a sine to ~2%, but a segment with an inflection between its extrema does not (measured 6% of amplitude on plain constraint bakes under an animated parent); the refinement brings any shape inside the bound for a few keys per curve.evaluate_hermiteevaluates such a sparse curve at any times (holding its end values outside the kept range) so a caller can measure a fit without a DCC; mayatk and blendertk'sreduce_to_extremesare thin writers over it. -
2026-09-05 -- a highlight shows through an emissive map (
file_utils/mesh_convert/glb_fades.py,_mesh_convert.py). glTF emission isemissiveFactor * emissiveTexture, and a mapped material arrives with the factor at white -- the map IS its emission -- so the additivehighlightchannel written over it clamped to a constant, and the map would have masked the colour everywhere it is black besides. Measured on a production circuit board (StingrayPBS with an emissive map): the GLB carried the pointer channel on every clip that moved, the log said highlighted, and nothing on screen changed.PointerChannelgains an optionalpreparehook that readies an isolated material before its base is read; thehighlightrow's (_uncover_emissive) drops the emissive map from the highlighted copy, with the strength that scaled it and the white factor that stood for it, so the copy glows from the channel alone -- the reference build's look (the whole board pulses its colour). A material with a uniform emissive is untouched. The conversion sweeps unreferenced images after the fades pass, so a map the highlighted material was the only user of does not ride the file as dead payload. Tests:test_mesh_convert+2 (test_a_highlight_shows_through_an_emissive_map,test_a_highlighted_materials_orphaned_emissive_map_is_swept). -
2026-09-05 --
IterUtils.find_flat_interior_indicesis O(n) again (iter_utils/_iter_utils.py). The band-around-first-value rewrite (2026-08-31) sliced the whole remaining array at EVERY index, so a smooth per-frame bake -- the input every optimize pass feeds it, and the one shape with no run to find -- cost an O(n^2) numpy scan: measured 5.9 ms per 1134-key curve, ~9 s of the 46 s a production-scaleoptimize_keysflat pass spent on 1580 baked curves. One vectorized adjacent-diff test now rejects any curve with no two neighbouring keys inside the band (a run needs one), and the scan itself runs on plain floats from the positions that test marks: 0.036 ms on the same curve (163x), identical output on every shape the contract tests cover plus stepped, held and creeping inputs. Same contract, same results;FlatInteriorContractTestunchanged. -
2026-09-05 --
GlbPipeline: the one FBX -> GLB build the Scene Exporters and the WebXR preview share (file_utils/mesh_convert/glb_pipeline.py,_mesh_convert.py,net_utils/preview/deliverer.py,core_utils/export_profile.py). The two producers of the same GLB reached it by two chains -- the exporters'create_glb(sidecar ->fbx_to_glb-> texture pass) and the preview deliverer's pass registry (downsize ->fbx_to_glbwithlightmaps=False-> sidecar readback -> prune -> lightmaps -> optimize), each with its own ordering, extras and failure policy -- and every "the preview shows X, the export ships Y" of the last month was a gap between them.GlbPipeline.build(src, dst, *, sidecar, lightmap_dirs, texture_params, downsize, scratch_path, release_source, progress, logger)is the single chain, andGlbPipeline.envelope(read_sections, source=, asset=)the single path to the scene-sidecar envelope (every producer hands it its host'sSceneState.read; a read that raises degrades to an envelope with no sections, once, rather than one policy per caller --PreviewBridge._attach_sidecarnow takes the reader), and the callers hand it dials only: downsize the FBX's embedded textures to the texture ceiling first (FbxMedia.downsize, the measured 419 s -> 333 s),fbx_to_glbwith the envelope and the host's live map folders,optimize_glb_textureslast on the closed file.fbx_to_glbgainsreport=-- a dict it fills with the sidecar's per-section outcome and the lightmap coverage (MeshConvert.lightmap_report: what the manifest wanted of THIS file against what bound; the walk runs only when a report is asked for) -- and sweeps unreferenced textures itself (prune_glb_unreferenced_textures, after the sidecar and before the lightmap / shadow passes), so a conversion offered no envelope no longer ships Maya's IBL maps and pays to compress them; both used to live only in the preview's chain. Behaviour change (preview): a texture-pass failure now fails the push and says why, as it always failed the export -- the old per-pass guard published the raw GLB instead.PreviewDelivererkeeps the publish and the delivery container (ktx2_fallback=False, the viewer's texture format) and losesPAYLOAD_PASSES/EDIT_PASSES/FILE_PASSESandPreviewPassContext(removed from the root; extending the deliverable is now a change to the chain both producers run, seedocs/webxr_preview.md).ExportProfile(new this release) is trimmed to the export button's contract --legal_name/widget_key/value_method/read_values/run_config-- shared by the two DCC panels; the preset-decoding half (default_values,decode_stored,options,PlainTooltipFormat) went with the headless preview route it existed for. Tests:test_glb_pipeline(new, 12),test_mesh_convert+7 (fbx_to_glb(report=),lightmap_report, the in-chain sweep),test_preview_serverreshaped around the one contract (the deliverer hands the pipeline its dials; 150 pass),test_export_profile8. -
2026-09-05 — the horizon rig's map is a height-field ray march; the coverage-aware horizon map is retired (
geo_utils/shadow_horizon.py,geo_utils/shadow_horizon.glsl,img_utils/_img_utils.py,net_utils/preview/scripts/shadow_rig.js). Asked as "the horizon rig gives bad results ... improve how it works to give efficient but accurate shadow profiles". Measured first (a 192-px canvas against the exact projection at three lights,test/temp_tests/_horizon_accuracy.py): the coverage map disagreed 3 % on a box, 7 % on a table, 8 % on a chair (20 % under a grazing light), 14 % on a lamp, 19 % on a stool with stretchers, 22 % on an arch -- thin members' shadows as bin-quantised combs, a seat over a stretcher filled solid (one interval per column per layer), a lintel smeared across bins: the representation, not bugs. A depth-peeled height-field march on the same canvas: 1 / 2 / 2 / 1.6 / 2.8 / 5 %, baked in 0.03-0.06 s instead of 2-5 s, in a 128 x 512 texture instead of 1024 x 512. NewHeightFieldMap(HorizonMapstays as its alias for one release; nothing decodes encoding 1 any more): per footprint pixel up to K solid spans (16-bit heights), the distance to the nearest solid column and its hull for the penumbra, and a min/max pyramid the shader skips empty space with (7-16 steps per ray on average, p98 32-106, against 300-550 for a plain march);to_rgba/from_rgbaround-trip exactly (the bake quantises as the PNG will hold).ImgUtils.rasterize_height_spansdepth-peels the footprint from the surface crossings at each pixel centre, paired by the faces' orientation -- a duplicate crossing from a quad's shared edge had paired into zero-thickness slivers, and a face below the ground is now clamped to it instead of dropped (a box cut by the ground had no column);rasterize_height_fieldsis its one-span hull.ShadowHorizon.bake(size=, spans=, bounds=, padding=)-- 10 % padding by default, which is also how far a penumbra reaches sideways;bake_adaptivewalksADAPTIVE_SIZES(128, 256). The reference is an exact walk (Amanatides-Woo over every pixel the ray crosses) and the shader a conservative quadtree traversal of the same texels, so they agree to the pixel: Maya's device check reads mean |d| 0.0000 on both devices; a Python port of the GLSL agrees with the reference to 1e-9 on the viewer fixture. Two things the pins taught: 8-bit heights cost the arch and lamp 1-2 points, so heights are 16-bit; and a per-pixel lateral distance jumped the penumbra a quarter width when a boundary-hugging ray landed one pixel over in float32 (0.21 vs 0.07 in the viewer), so the distance field is read bilinearly at the ray's midpoint. The viewer shim reads the new block (mapping: heightfield,encoding: 2,size,spans,levels,bounds,height_scale) and refuses the old one to the silhouette; its live tests bake a real post through the same bake and find the penumbra points on the reference itself.test_shadow_horizonrewritten (24; the measured bounds now 0.00-0.2 % tolerant),test_shadow_web30. -
2026-09-05 — height-field map: the pyramid bounds what the march reads, the footprint is square, the shader carries its boundary (
geo_utils/shadow_horizon.py,geo_utils/shadow_horizon.glsl,img_utils/_img_utils.py). Critique of the above. The pyramid's hull was the cell dilated by its eight neighbour cells; the march reads an empty pixel's nearest hull bilinearly from the pixel ring round the ray's midpoint, and that pixel's nearest column can lie a cell and a half away -- outside the dilation, so a column cell could be skipped whose exact answer was a penumbra. The hull is now the nearest hull of the cell's pixels and their one-pixel ring (exactly the reads), and a cell without a column carries none. The distance field is now measured in the smaller pixel pitch with the other axis weighted by the footprint's aspect (HeightFieldMap.aspect; the reference and the shader scale their half-pixel slack the same way): it was counted in pixels, so an elongated footprint's non-square pixel stretched the penumbra's lateral clearance along one axis (a 2:1 bench read it at half the truth). A square footprint was tried first and rejected: it halved a shelf's and an arch's short-axis texels, 2-3 % disagreement becoming 4.3. The shader rebuilt the ray's position fromo + d * tafter every cell, which float32 lands a hair short of an integer boundary once the footprint's size times the ulp passes the 1e-4 nudge (512 px): it re-entered the cell it had left until the step cap. The crossed boundary is now carried exactly. The rasterizer's shared-edge dedupe compared crossing heights bit-exactly; two triangles' interpolations of one edge agree only to rounding, and an unmatched entry filled the gap up to the next crossing solid -- now a relative tolerance.HeightFieldMap.from_rgbarejects a size that is not a power of two (the pyramid could not be derived). NewShadowHorizon.record(...)builds the record'shorizonblock, so mayatk and blendertk stop spelling the schema twice. A line-for-line Python port of the shader still agrees with the reference to 1e-9; every engine pin re-run. -
2026-09-05 -- a GLB with no clip still gets its authored tracks, and an open preview tab reloads itself when the viewer page changes (
file_utils/mesh_convert/_mesh_convert.py,net_utils/preview/server.py,net_utils/preview/viewer.html). Investigating a highlight that did not show in the WebXR preview: (1) both animation passes (apply_glb_visibility,apply_glb_fades) bailed when the file carried no animation at all, which is what an FBX exported with animation OFF converts to (Blender always; Maya whenever its hollowTake 001stack is absent) -- yet thedata_exportcarrier still declared visibility, opacity and highlight tracks with nothing to ride on. NewMeshConvert._synthesize_clipsgives such a file one empty clip per declared take, else oneDEFAULT_CLIP_NAME(Take 001) clip over the tracks' own extent; the passes write into it as into a converter-made clip, the origin is inferred from the publishedclip_spanexactly as for the hollow stack (so keys land at the same instant animation on or off), andprune_glb_animationsdrops a clip that stays hollow. (2) The preview page pollsmanifest.jsonand swaps the MODEL, but never the JavaScript running it, so a viewer fix reached a tab left open only via F5 -- the page from before the emissive binding existed kept playing a GLB that carried the channel. The manifest now names a fingerprint of the served page (viewer, sha1 ofviewer.html, refreshed on every publish) and the page reloads itself when it changes. Tests:TestApplyGlbHighlight+2 (no clips / no clips and no takes),test_preview_server+2 (stamp shape and stability; absent without the page), livetest_a_superseded_page_reloads_itself(real page, the fingerprint bumped under it, the page comes back ready with the model).test_mesh_convert306 passed,test_preview_server157, live viewer 21. -
2026-09-05 --
VidUtils.resolve_ffmpeggains thepromptconsent seam andVidUtils.ensure_ffmpeg(prompt)is the panel-side entry point (vid_utils/_vid_utils.py). The ffmpeg resolver was the oneresolve_*(auto_install=True)with noprompt, so every panel that wanted to offer the managed download rolled its own dialog before calling it silently (the Audio Clips panel) or, worse, left the engine to raise after the work ffmpeg was needed for: the Maya playblast exporter captured a full viewport sequence and only then failed the MP4 with "FFmpeg is required".promptdefaults toFalsehere -- unlike the sibling resolvers -- becauseauto_install=Truealone has always installed silently and callers rely on it;ensure_ffmpegmirrorsImgUtils.ensure_ktx2_encoder(returns the path it INSTALLED,Nonewhen ffmpeg was already there, raises the fix-shapedFileNotFoundErroron a decline or a failed install). Red tests intest_vid.py(declined download installs nothing and names the decline; consented download installs; the silent default is unchanged).