github m3trik/pythontk v0.9.36
pythontk v0.9.36

6 hours ago
  • 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): with carry_gap the first moved shot's window opens just past after_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 at before_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_core 146.

  • 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.fractions now 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's canvas keeps its shape; every reader -- the Maya expression, the Blender drivers, unitytk's ShadowPlaneController.cs and the viewer's placement -- changed in lockstep. test_shadow_projection rewrites the attachment tests around the anchor's fraction (25); test_shadow_web 30.

  • 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) -> objects replaces shot.objects as the list handed to move_keys in 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_adopt has 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 off FAILED_CMPT_CASE_LOC curves. Undo and redo now restore the ledger with the bounds (TestBoundaryLedger.test_the_edit_ledger_rides_the_restore_point); test_shots_core 140/140.

  • 2026-09-05 -- MathUtils.reduce_samples and MathUtils.evaluate_hermite: a bounded reduce-to-extremes (math_utils/_math_utils.py). reduce_samples is 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 than max_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_hermite evaluates 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's reduce_to_extremes are 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 is emissiveFactor * emissiveTexture, and a mapped material arrives with the factor at white -- the map IS its emission -- so the additive highlight channel 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. PointerChannel gains an optional prepare hook that readies an isolated material before its base is read; the highlight row'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_indices is 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-scale optimize_keys flat 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; FlatInteriorContractTest unchanged.

  • 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_glb with lightmaps=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, and GlbPipeline.envelope(read_sections, source=, asset=) the single path to the scene-sidecar envelope (every producer hands it its host's SceneState.read; a read that raises degrades to an envelope with no sections, once, rather than one policy per caller -- PreviewBridge._attach_sidecar now 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_glb with the envelope and the host's live map folders, optimize_glb_textures last on the closed file. fbx_to_glb gains report= -- 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. PreviewDeliverer keeps the publish and the delivery container (ktx2_fallback=False, the viewer's texture format) and loses PAYLOAD_PASSES / EDIT_PASSES / FILE_PASSES and PreviewPassContext (removed from the root; extending the deliverable is now a change to the chain both producers run, see docs/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_server reshaped around the one contract (the deliverer hands the pipeline its dials; 150 pass), test_export_profile 8.

  • 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. New HeightFieldMap (HorizonMap stays 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_rgba round-trip exactly (the bake quantises as the PNG will hold). ImgUtils.rasterize_height_spans depth-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_fields is its one-span hull. ShadowHorizon.bake(size=, spans=, bounds=, padding=) -- 10 % padding by default, which is also how far a penumbra reaches sideways; bake_adaptive walks ADAPTIVE_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_horizon rewritten (24; the measured bounds now 0.00-0.2 % tolerant), test_shadow_web 30.

  • 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 from o + d * t after 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_rgba rejects a size that is not a power of two (the pyramid could not be derived). New ShadowHorizon.record(...) builds the record's horizon block, 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 hollow Take 001 stack is absent) -- yet the data_export carrier still declared visibility, opacity and highlight tracks with nothing to ride on. New MeshConvert._synthesize_clips gives such a file one empty clip per declared take, else one DEFAULT_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 published clip_span exactly as for the hollow stack (so keys land at the same instant animation on or off), and prune_glb_animations drops a clip that stays hollow. (2) The preview page polls manifest.json and 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 of viewer.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), live test_a_superseded_page_reloads_itself (real page, the fingerprint bumped under it, the page comes back ready with the model). test_mesh_convert 306 passed, test_preview_server 157, live viewer 21.

  • 2026-09-05 -- VidUtils.resolve_ffmpeg gains the prompt consent seam and VidUtils.ensure_ffmpeg(prompt) is the panel-side entry point (vid_utils/_vid_utils.py). The ffmpeg resolver was the one resolve_*(auto_install=True) with no prompt, 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". prompt defaults to False here -- unlike the sibling resolvers -- because auto_install=True alone has always installed silently and callers rely on it; ensure_ffmpeg mirrors ImgUtils.ensure_ktx2_encoder (returns the path it INSTALLED, None when ffmpeg was already there, raises the fix-shaped FileNotFoundError on a decline or a failed install). Red tests in test_vid.py (declined download installs nothing and names the decline; consented download installs; the silent default is unchanged).

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.