-
2026-09-15 -- client program identifiers are out of pythontk's tracked source (
file_utils/mesh_convert/_mesh_convert.py,export_verify.py,str_utils/_str_utils.py,net_utils/preview/viewer.html, and test data in eight test modules). Measurement notes and synthetic test data named a client's programs and objects; they now carry neutral names, with equal-length stand-ins wherever a test measures string lengths, and older entries in this file use the same stand-ins. m3trik's release gate (scripts/check_public_hygiene.py) keeps identifiers out:push.ps1runs it over everything a release would publish, reading a denylist kept outside every public repo, since a pattern list in public source would publish the names it guards. The built-inspeedrunmapping template keeps its name: the word is generic, and renaming a shipped template would break the users who select it. -
2026-09-15 --
KeyStashowns the undo step and the re-read both DCC adapters need (core_utils/engines/key_stash/key_stash_model.py). New_undo_step(name)batches the store's mutations and writes the record inside the adapter's undo chunk on the way out, through the_undo_chunkand_save_in_stephooks.active()re-reads the record in place when the backend reportsrecord_changed(), an optional addition to the persistence protocol, andis_previewingmoves here from both adapters, where it was identical. No flush may run mid-step: mayapy'sevalDeferredand background Blender both flush at once, and that write landed outside the step, where no undo could take it back (measured in both adapters). The in-step write also runs when the operation raises, since the batch's own exit flush would write throughsave(), outside the Maya adapter's undo queue.test_key_stash_core+3, each failing first. -
2026-09-15 -- the deliverable gates read a declared Full Sequence Only mode, and the export check table schedules the new UV snapshot report (
file_utils/mesh_convert/export_verify.py,_mesh_convert.py,core_utils/export_profile.py).ExportVerifier.check_fbx_takesfailed a correct Full Sequence Only FBX "declared but absent" on every shot, andcheck_clips_vs_takesdid the same to its GLB:fbx_takeslists the scene's shots in every mode. Both now read the mode the exporter declares on theshot_metadataenvelope (newMeshConvert.SHOT_CLIP_MODE_KEY, besidefps):fbx_takesSKIPs with the reason, and the clips gate still judges the whole-timeline clip against the shots' range but stops calling them missing. A file that does not declare the mode, or declares a shot-bearing one, still fails -- one stack alone is also what a split that silently failed leaves.ExportProfile.CHECK_DEPENDENCIESgainscheck_uv_snapshots(mayatk's report of leftover auto-unwrap UV snapshots, which no task changes).test_export_verify+2, the Full Sequence one failing first. -
2026-09-15 -- every lightmap copy inside a GLB says what the GLB ships, whoever produced it, and a task can record an edit it keeps (
file_utils/mesh_convert/_mesh_convert.py,core_utils/task_factory.py).apply_glb_lightmapscorrected the per-nodelightmapInfomarkers but left the data_exportlightmap_metadatamanifest naming the source.exrat the pre-normalisation intensity (measured on theTestGlbLightmapsfixture:room_Lightmap.exr@ 1.0 besidelightmap_web'sroom_Lightmap.png@ 0.5, only its locate hints gone): the correction was keyed by node name, and no node is named after an entry of the carrier's manifest. Entries are now matched to their nodes by the binder's own rule (_resolve_lightmap_node, namespace-tolerant) and corrected only when every node an entry resolves to ships one lightmap; an unbound entry, and a manifest newer than the reader, are left as found. NewMeshConvert.fix_glb_lightmap_metadata(glb)is the same repair for a GLB the applier never touched: blendertk's native glTF export shippedfloor_mat_Lightmap.exr@ 1.0 with the absolute authoring folder asdir, besidefloor_mat_Lightmap.png@ 3.191705 (Blender 5.1). It feeds itself: each node's values are read off the file (_lightmap_final_values-- the materials its primitives wear, looked up inlightmap_web), which is also whatapply_glb_lightmapsnow hands the reconcile in place of a second bookkeeping dict. A node wearing two different lightmaps is not guessed at, and a file with nolightmap_webis left as found.lightmap_webis read where the viewer reads it (_lightmap_web_manifest: the first scene's extras, as a native export writes it, then the root's), soverify_glb'slightmapflag,set_glb_normal_scale(lightmapped_only=True)andoptimize_glb_textures' lightmap exemption stop reading a natively exported deliverable as unlit (the texture pass re-encoded such a lightmap lossily wherever it was sampled off texCoord 1).TaskFactory.record_kept_edit(label)andkept_editsare the other half ofstage_deferred_restore: a task names an edit no restore unwinds where it makes it, so a run that stops before its write can name exactly what it left, andrun_deferred_restorescloses the run and clears the record.test_mesh_convert+7 andtest_task_factory+1, each failing first; five mutations of the fix (no node values, no ambiguity guard, a root-only probe, an untouched manifest, a record never cleared) each fail the tests written for them. Full suite: 4714 tests, 0 failures, 14 skipped. -
2026-09-14 -- a UDIM set is ONE material to the shader builders, and a named asset converts every tile (
core_utils/engines/textures/map_factory/_map_factory.py).MapFactory.group_textures_by_setkeeps a set per tile on purpose -- the factory converts each tile's images on their own -- and both DCCs'GameShader.create_networkbuilt a shader per set, so a 10-tile material became 10 shaders.MapFactory.collapse_tile_sets(new) is the shader builder's view over those sets: one set per material, each map once, as its lowest tile. It keeps a real tile rather than a<UDIM>pattern because the hosts tile from one -- Maya'suvTilingModeand Blender'sTILEDsource both find every sibling tile from the file they are given, while Blender loads a literal<UDIM>path as a one-tile image (measured, 5.1). A NAMED asset (prepare_maps(group_by_set=False)) had a silent drop of its own: its single inventory holds one path per map type, so a merged 2-tile set converted tile 1001 alone and never wrote 1002 (measured: the call wroterock_Base_Color.1001.pngonly). Its tiles now convert a tile at a time and come back as one list.MapFactory.get_tile_paths(new) lists a set's tiles on disk, for a host deciding whether to tile at all: a lone tile-numbered file is not a set, and tiled,wall_BaseColor.1024.pngleaves 0-1 UVs for tile 1024 and renders black, where read as one image it renders wherever the UVs sit -- so both DCCs tile only a set of two or more.test_map_factory_grouping+4 (a material's tiles collapse into one set; the lowest tile and the material's untiled maps are kept; a named asset converts every tile; a set's tiles on disk for each scheme and for its pattern path, never another container, a resolution tag or another scheme), all red first; the full suite green, 4706 tests, 0 failed. -
2026-09-14 -- a grayscale roughness or metallic map reaches the GLB with its values: FBX2glTF converts a copy whose grayscale embeds carry every colour channel (
file_utils/mesh_convert/fbx_media.py,_mesh_convert.py). FBX2glTF 0.13.1 packs a material's occlusion-roughness-metallic texture from each map's red, green and blue and reads a channel the decoded image lacks as white, so a grayscale map shipped as roughness 1 and metallic 1 -- and metallic 1 renders black under a lightmap. Measured on Maya-built twins and on a synthetic Stingray PBS quad (roughness a 60..220 ramp, metallic 8): stored RGB the packed texture carries G 60-220 / B 8; storedL,LA, 16-bit or as a grayscale JPEG it carries 255 in both, per map (a grayscale roughness beside an RGB metallic whitens green alone), while a palette map packs correctly. It is the common case --GameShadersplits a packed ORM intoLmaps -- and until now only the scene sidecar's diff-basedmetallic_roughnessrepair covered it, so a material or producer that section skipped shipped metallic 1.FbxMedia.expand_grayscale(new) re-encodes every grayscale PNG/JPEG embed as RGB (RGBA with alpha; 16-bit rescaled over its full range, where Pillow's own conversion clips it to white), andMeshConvert.fbx_to_glbhands the converter that copy: nothing is written without a grayscale embed, and a failed expansion logs and converts the file as before. The copy lives in a store of the call's own, removed on every exit (an interrupted expansion included), and a converter failure names the source FBX rather than the copy it read. Fixed at the converter's input rather than Maya-side (the other option on file), so the blast radius is exactly the broken input and an FBX from any producer is repaired; re-measured, the Maya-builtL, mixed and ORM-split twins now pack like their RGB twin throughfbx_to_glb, while the bare binary still packs them white.FbxMedia.downsizenow shares the decode/encode/write loop (_rewrite_images). Cost: one full payload read per binary-FBX conversion (2.2 s on a synthetic 190 MB, 820k-record file). Left alone, on measurement:suspect_orm_materialsstill tests blue only -- with no roughness map FBX2glTF bakes the roughness scalar into green, so green 255 is an ordinary fully rough material, not a fault.test_fbx_media+2 (each grayscale mode expands carrying its value in every channel, with colour, palette and non-PNG/JPEG embeds copied byte for byte; a file with none is not written) andtest_mesh_convert+3 (a grayscale map packs like its RGB twin through the real converter; the converter alone still packs it white; the conversion reads the expanded copy and survives a failed expansion) -- red first except the converter-alone test, which pins the premise so an upgrade that fixes it says the pass can go. The two converter tests run an FBX2glTF that is already installed and skip otherwise, onbuild_stingray_fbx: a synthetic FBX, since the SDK reads nothing without aFileId/CreationTimeheader and binds no texture without theTexture/Videorecords' own properties. -
2026-09-14 -- the GLB presence gate reads presence from the authored channels, so an opacity nothing mirrored is gated, and a one-frame cut resolves to its later key (
file_utils/mesh_convert/_mesh_convert.py).apply_glb_visibilitygated only tracks carrying avisibilitykey, so a hand-keyed opacity shipped ungated unless some producer had written a mirror first -- which inside one export bracket came down to producer ORDER (the WebXR preview's track walk runs before the render-effects preparer repaired a missing mirror), and is whyeffect_preview_channelssynthesized a mirror of its own.MeshConvert._presence_keysis now the one definition of presence -- a real ramp gates on the ramp (as before), else the authoredvisibility, else the opacity keys read as> 0, else nothing (a highlight alone) -- andapply_glb_visibilityselects and ranges its tracks by it; the synthesized mirror is gone. Found beside it:_visibility_runssorted keys by (frame, value), so two keys on one frame always resolved to the larger value and every one-frame cut to hidden read as visible; keys sharing a frame now keep their authored order, the tie rule_strictly_increasingalready applies. A fading ramp that cuts to zero on one frame had already met it: its presence runs put both states on that frame, so the node stayed present, at zero alpha, until the next key.test_mesh_convert+6 (a mirrorless fade gates clip for clip like its mirrored twin; a mirrorless one-frame cut gates on the no-takes path; the presence fallback order; same-frame ties in authored order; a fade that cuts to zero on one frame is absent from the cut; an opacity overlay gates end to end) and 1 rewritten (the preview's opacity track carries only its ramp) -- all red first except the overlay guard, which pins the path the synthesized mirror used to carry. -
2026-09-14 -- Export Playblast has no keyboard shortcut, and a test fails any key two handlers claim (
net_utils/preview/scripts/playblast.js,viewer.html). Onerpress toggled real scale AND opened the recording prompt. The page acts on a key first and then hands the same event to every script (emit('key', event), last on purpose so a script can never shadow a page shortcut), so a script binding a page letter adds a second action instead of replacing the first. Playblast loses the shortcut rather than moving to a free letter (maintainer call): the button is the one way in, and its tooltip stops advertising(r).inspect(i) andturntable(t) never collided.test_preview_server+2:test_no_two_handlers_claim_the_same_shortcutgives every key a keyboard handler of the page or a packaged script compares against one owner -- read in either quote, under any parameter name and through aswitch, acodecounting as the key it presses (code === 'Space'iskey === ' '); red against the shippedrpair, and it refuses to pass for a handler it reads no key from -- andtest_the_shortcut_reader_sees_every_spellingpins that reader on each spelling. -
2026-09-14 -- the WebXR page's Export Playblast prompt offers quality presets, and the default records at 2560 px instead of 1280 (
net_utils/preview/scripts/playblast.js,viewer.html,playblast.py). Every recording was a 1280 px long edge at CRF 16, with no way to ask for more short of editingMAX_EDGE. The prompt now has a Quality picker -- Draft (1280 px, CRF 23), Standard (1920, CRF 20), High (2560, CRF 16, default), Maximum (3840, CRF 16) -- remembered for the tab. The frame is rendered at the preset's size rather than upscaled: a smaller view has its pixel ratio raised for the recording (clamped to the GPU'sMAX_RENDERBUFFER_SIZE/MAX_VIEWPORT_DIMS) and restored once the last frame is in.PreviewPlayblast.begintakesquality(0-100, refused otherwise before a frame is sent; None keeps the recorder's own) andfinishencodes with it.viewer.showDialoggains a'choice'field type. The saved status line states the written size.test_preview_playblast+3,test_preview_viewer_live+1. -
2026-09-14 -- GLB skins: every conversion drops the skinning data no node binds and re-points each skeleton at its joints' common root,
ExportVerifierfails a skeleton that is not one, andfbx_to_glbreplaces its output only after FBX2glTF succeeds (file_utils/mesh_convert/_mesh_convert.py,export_verify.py,fbx_media.py,file_utils/_file_utils.py). The Khronos glTF-Validator rejected a production assembly with SKIN_SKELETON_INVALID x7: FBX2glTF names a skin's first joint as its skeleton, and a sheared-chain flatten re-parents the joints side by side under their group -- three.js never reads the field, andcheck_glb_skinshad no gate for it.MeshConvert.fix_glb_skin_skeletonsre-points an invalid skeleton at the closest common root (or removes it where the joints share none);MeshConvert.prune_glb_unused_skinsdrops skins no node references andJOINTS_n/WEIGHTS_non meshes no skinned node instantiates (that file: 255 of 262 skins, 836 meshes, 0.49 MB) and repacks the BIN.fbx_to_glb(overwrite=True)deleted the existing GLB BEFORE a conversion that runs 13 minutes on that assembly, so a failed or timed-out one cost the file it was to replace; FBX2glTF now writes into a scopedTempArtifactsstore whose GLB replaces dst on success through the newFileUtils.atomic_write: copied to a sibling.partand swapped in by oneos.replace, because a move copies straight over dst whenever the store sits on another volume (and on Windows on any volume, whose rename refuses an existing file), so a disk-full or I/O error part way through truncated the deliverable.FbxMedia's.partwriter is the same primitive, promoted by its caller once the mmap closes. A destination another process holds open (FileUtils.is_locked) raisesPermissionErrorbefore the conversion rather than at that replace. The prune leaves a primitive whoseKHR_draco_mesh_compressionextension names its attributes a second time.test_mesh_convert+7,test_export_verify+1,test_file+4. -
2026-09-14 --
ExportRun: the GLB key tolerance rides Optimize Keys, and a GLB-only run drops the FBX-side key hygiene (core_utils/export_profile.py); the GLB texture pass starts its longest encode first, andclip_spanscan ask per window (file_utils/mesh_convert/_mesh_convert.py).glb_key_toleranceis now the GLB half of ONE choice: FBX2glTF resamples every frame, so the DCC-side optimisation never reaches the GLB and the tolerance is inert (a note says so) whileoptimize_keysis off; the table's OFF readsKeep Every Key, andSECONDARY_MAX_SIZE_OPTIONS' sentinelSame As Other Maps. Withoutput_format == "glb"the FBX is a temp intermediate the converter resamples per frame, soFBX_KEY_HYGIENE(optimize_keys,snap_keys_to_frame,tie_all_keyframes,check_floating_point_keys,check_untied_keyframes) is popped with a note -- Optimize Keys alone was 98 s of a production run (plus SmartBake's own pass, which follows the level) for keys the deliverable never sees.optimize_glb_texturesorders its pool longest-source-first (_largest_first): fed in insertion order it ran a 260 s UASTC+RDO encode alone at the tail of a 302 s pass.MeshConvert.clip_spans(key_spans=)is the same question asked per window -- the whole timeline, then each valid take -- for a DCC that can seek its curves instead of handing over every key time (listing a production bake's millions cost mayatk 10 s a pass); same take parsing, same answer.Ktx2Encodersizes each toktx budget from the file it encodes (AUTO_TIMEOUTdefault,encode_timeout: the 300 s floor, or 60 s per megapixel): the flat 300 s shipped two 4K UASTC+RDO normal maps as PNG on a loaded host (260 s on a quiet one). Breaking:Ktx2Encoder(timeout=)defaults toAUTO_TIMEOUTinstead of 300, so.timeoutreads-1.0unless a caller passes a number; the budget one encode gets isencode_timeout(width, height).test_export_profile+2,test_mesh_convert+2,test_ktx2_encoder+1 (and its STEP test's lambdas are defs). -
2026-09-13 — GLB sampler evaluation: a STEP value holds FROM its key (
file_utils/mesh_convert/glb_clips.py).GlbClips._evaluate-- behindGlbKeyReduction.evaluate/deviationand the clip cutter's boundary pins -- found the upper key withbisect_left, so a time that IS an interior key paired that key with its predecessor: LINEAR still read the key (u == 1), but STEP held the previous value and read every gate one key late at its own change point. Surfaced as a 1.0 deviation between two IDENTICAL production gate samplers in an accuracy check; the deliverables were never affected (_windowpins a bound only when no key sits on it).bisect_rightnow;test_mesh_convert+1,test_glb_key_reduction+1 and the STEP dedupe test pins its change-point values absolutely. -
2026-09-13 — GLB deliverables: a secondary-map ceiling, UASTC RDO and tolerance-bound key reduction, all through the shared web-delivery policy (
file_utils/mesh_convert/glb_key_reduction.py,_mesh_convert.py,glb_pipeline.py,img_utils/ktx2_encoder.py,core_utils/export_profile.py).MeshConvert.optimize_glb_texturestakessecondary_max_size-- a lower longest-edge ceiling for the packed data maps alone (SECONDARY_SEMANTICS: metallic-roughness / occlusion; color and normals keep the primary) -- anduastc_rdo, the--uastc_rdo_llambda for every UASTC encode, a normal map capped atUASTC_RDO_NORMAL_MAX(0.75, toktx's own guidance);Ktx2Encodermodels the flag (constructor default + per-call override, toktx's 0.001-10 range enforced) instead of leaving it toextra_args. Both are keys ofweb_delivery_texture_params(policy: off), so every producer inherits the same defaults. NewGlbKeyReduction(MeshConvert.reduce_glb_animations, aGlbPipeline.build(key_tolerance=...)stage between the conversion and the texture pass, its summary the report'sanimation:Nonewithout a tolerance, and on a.glbsource, whose report carries every key a build's does): FBX2glTF bakes a key on every frame, and each LINEAR sampler now keeps only the keys that reproduce every original sample within the bound under the viewer's own interpolation (Ramer-Douglas-Peucker; shortest-arc slerp for rotations, sign-agnostic), STEP samplers drop repeated values losslessly, CUBICSPLINE and shared outputs are refused, and both ends of every sampler stay so no clip changes length or origin. Measured on a 4K production assembly: 2.18 M keys, 6.8% needed within 1e-4 (0.1 mm / 0.006 degrees), 23.5 MB of animation.ExportRuncarries the three modes (secondary_max_size,uastc_rdo,glb_key_tolerance;from_taskspops and coerces them) andExportProfilethe combo tables (SECONDARY_MAX_SIZE_OPTIONS,UASTC_RDO_OPTIONS,GLB_KEY_REDUCTION_OPTIONS).test_glb_key_reduction(new, 10),test_mesh_convert+4,test_ktx2_encoder+1,test_export_profile+1,test_glb_pipeline+2. Found alongside and fixed:compact_glb_animationsre-pointed a collapsed output accessor to its fresh two-key view without clearing a non-zero accessorbyteOffset(legal glTF -- a packer sharing one view between accessors), so such a channel read past the payload it had just been given; the offset is cleared now, as the reduction pass does (test_mesh_convert+1, failing first). -
2026-09-13 — the Scene Exporter's shared contract grows a per-run value object, the shared tables and the texture staging pass;
TaskFactorydrops itsset_/revert_pairing (core_utils/export_profile.py,core_utils/task_factory.py,core_utils/engines/textures/map_optimizer.py).ptk.ExportRunis the frozen dataclass of ONE export run's modes -- output format, texture file type (+ KTX2 fallback), the two write-back flags, the size dial, the template, the verification row, the task-derived Optimize Keys level and relative-paths flag, the deliverable path and versioning -- withfrom_tasks(tasks, texture_file_types), the one copy of the parse both DCC exporters applied inline (legacycreate_glb/glb_texture_format/optimize_textures_write_backspellings included; an unknown texture file type comes back as anerrornote, an unknown output format as awarningand FBX).ExportProfilenow also owns the pipeline's declarative tables --TASK_ORDER, the phase tuples andCHECK_DEPENDENCIES(verbatim from mayatk), the combo tables (OUTPUT_FORMATS,EXPORT_MODE_OPTIONS,TEXTURE_OUTPUT_OPTIONS,ANIMATION_OUTPUT_OPTIONS,TEXTURE_MAX_SIZES,BAKE_RANGE_OPTIONS,ANIMATION_CLIPS_OPTIONS,OPTIMIZE_KEYS_OPTIONS, and the registry-derivedoptimize_textures_options()/texture_file_type_options()/frame_rate_options()) -- plus thescoped_tablesclass decorator that hands a manager the tables scoped to the tasks and checks it implements andunimplemented(manager), which names the rest (the parity gap a mirror declares).MapOptimizer.stage_maps(sources, assess, ...)is the file half of both exporters' Optimize Textures task: judge each source (recompressible-but-optimal PNGs included), claim every output name serially (same-named maps from two folders stage intoaltN; a write-back collision skips the loser), encode in parallel, re-judge a durable staged copy reused from an earlier run and redo it when stale, discard a re-encode that saved underRECOMPRESS_MIN_SAVING; its report names, per source, the file to point the consumers at -- the DCC only repoints. The staging dir may be a callable resolved once something is pending, so a pass with nothing to stage creates no directory. Breaking:TaskFactoryno longer pairsset_<x>withrevert_<x>, so a task'srevert_<x>is no longer called for it -- a task whose state must be put back stages that withstage_deferred_restore(orstage_deferred_context). That revert fired whenrun_tasksreturned, BEFORE the work the tasks prepared for, so every shippedset_task had to disarm it -- the deferred-restore registry is the one undo mechanism, and_manage_contextlost its state bookkeeping.test_task_factoryretargets the revert cases (test_export_profile+11,test_map_optimizer+5; 195 pass across the three). -
2026-09-13 -
Color.linear_from_srgb/Color.srgb_from_linear(core_utils/color.py). The scalar twins ofImgUtils.srgb_to_linear/linear_to_srgb(which take images and arrays), for a widget that shows a LINEAR channel value -- a DCC colour attribute, a glTF factor -- on a display: the first three components convert per IEC 61966-2-1, alpha passes through, and an HDR value past 1.0 is not clamped. Measured need (2026-09-13): the Render Effects option box painted linear highlight colours as display colours, so the WebXR page, which encodes them, read much brighter than the box.test_color+4. -
2026-09-13 —
ExportVerifierreports what each GLB image costs, andMapOptimizer.optimize_mapsencodes a batch of maps in threads (file_utils/mesh_convert/export_verify.py,core_utils/engines/textures/map_optimizer.py,.../_mesh_convert.py,.../fbx_media.py). New gateglb_image_byteslists the GLB's embedded images by size (read from the JSON chunk'sbufferViews; nothing is decoded), largest first, and WARNs past a newExportVerifier(max_image_bytes=)bound; it never FAILs, since the file already shipped. It measures the number a source-texture size limit only stands in for: a production 57 MB source PNG shipped as a 3.12 MB KTX2.MapOptimizer.optimize_maps(requests, workers)isoptimize_mapover many maps in a thread pool -- one(written, error)per request, in request order, a raising map costing no other its result -- for the scene exporters' Optimize Textures pass, which now re-encodes every shipping PNG (a 4K map is 1.6-7 s on its own). Its thread count is the newImgUtils.encode_workers()--ENCODE_WORKERS(8) capped by the core count, or a requested count verbatim -- the one memory-capped encode policy the GLB texture pass,FbxMedia's embedded-image rewrite, this batch and the DCC exporters' passes now share;MeshConvert.OPTIMIZE_WORKERSstays one release as a deprecated, read-only alias ofImgUtils.ENCODE_WORKERSand goes in the next (assigning it no longer changes the pass: setImgUtils.ENCODE_WORKERS, or passworkers=).MapOptimizer.RECOMPRESSIBLE_FORMATS/RECOMPRESS_MIN_SAVING/is_recompressiblename the containers a plain re-encode can shrink (PNG) and the saving that makes one worth shipping (5%).test_export_verify+1,test_map_optimizer+2,test_mesh_convert+1 (the alias). -
2026-09-13 - a name pattern can carry a version counter, and a version scan can cover a multi-file deliverable (
str_utils/_str_utils.py,file_utils/_file_utils.py,file_utils/mesh_convert/export_verify.py).StrUtils.resolve_name_pattern(keep=...)leaves the placeholders a later stage owns ({n:03d}) verbatim -- not filled, not reported unresolved, and not legalized, where the:in the spec used to be dropped as illegal -- and returnstemplate(the name as astr.formattemplate, literal braces doubled so a brace in a scene name cannot read as a field) andkept.FileUtils.next_version_number(directory, format, stem, ext, start, extensions)isnext_version_path's scan on its own, for a name that is a whole template with the counter anywhere; both takeextensions, sibling extensions whose numbers count as taken, so an.fbx+.glbdeliverable versions as ONE and a GLB-only one finds its own.glbs. A repeated{n}is now a backreference instead of a duplicate regex group (which raised).ExportVerifier's sidecar discovery falls back to a versioned deliverable's SERIES manifest (.asset.scene_data.jsonforasset_v003.glb), the one the scene exporters write; it looked for the file's own stem alone, so the dropped-take gates SKIPped for every versioned deliverable checked from disk.test_str+5,test_file+3,test_export_verify+3 (206 / 151 / all pass).ExportProfileowns the Scene Exporters' Output Filename rule (core_utils/export_profile.py) --NAME_WILDCARD,VERSION_TOKEN({n}),OUTPUT_EXTENSIONS(the files each output format ships),resolve_output_path(pattern + counter + those files ->path/paths/n, a counter spec an int cannot take numbered plainly),fold_legacy_naming(the retired Version pattern / Timestamp flag, for one release),strip_deliverable_extensionandnaming_report(diagnostics as(level, message)) -- so mayatk and blendertk resolve through one copy instead of two.test_export_profile+5. -
2026-09-13 - a WebXR push can preview an effect the scene does not carry, and the render-effect key shapes are planned once (
math_utils/ramp_keys.py,file_utils/mesh_convert/_mesh_convert.py,.../glb_pipeline.py,net_utils/preview/bridge.py,.../deliverer.py). NewRampKeys(pulse,fade,fade_loop,pulse_gaps,frames) is the pulse/fade plan mayatk's and blendertk's writers each carried a copy of. NewPreviewBridge.push(data_export=...)->GlbPipeline.build(data_export=)->MeshConvert.fbx_to_glb(data_export=)overlays in-band channels for that build only (MeshConvert.overlay_data_export, applied ahead of every pass that reads one;Noneclears a channel), andMeshConvert.effect_preview_channelsstates one render effect as that overlay: the scene's tracks replaced, the take list and shot record cleared, one clip over the ramp's own extent. The build report,GlbPipeline.build's result and the deliverer's (whatpushreturns) carrydata_export, the channel keys the overlay replaced ([]when none landed), so a caller can tell an applied overlay from one a stale bridge never forwarded -- measured 2026-09-13 as a preview that looked the same whatever the panel was set to. A caller that passes no overlay builds exactly as before. -
2026-09-13 -
FileUtils.get_classes_from_pathno longer imports a file that defines no class (file_utils/_file_utils.py). It parsed each file for classes and then imported it regardless, and importing runs the module. Switchboard slot discovery scans whole package trees with it, so a class-less script inside a package executed at every DCC start: a scratch probe left inmayatk/mayatk/test/temp_tests/launched a fresh Maya on import, whose own startup scan imported it again -- a new, hung Maya every ~20 s. Results are unchanged (a file without a class never yielded a row). -
2026-09-12 - a KTX2 web deliverable ships without PNG/JPEG fallback twins (
file_utils/mesh_convert/_mesh_convert.py,net_utils/preview/deliverer.py).MeshConvert.web_delivery_texture_paramsnow carriesktx2_fallback=False(WEB_DELIVERY_KTX2_FALLBACK), andoptimize_glb_texturesdefaults to it. Breaking:optimize_glb_textures(ktx2_fallback=)now defaults toFalsewhere it wasTrue, so a caller that counted on the default for the twins passesTrue. The twin -- a PNG (normals, ORM) or JPEG (colour) copy of each KTX2 image, read only by stock importers -- was left to each producer, the scene exporters kept it, and a production 4K GLB carried 145.8 MB of twins beside 123.1 MB of KTX2: 48% of a 302.6 MB file, 156.9 MB without them. Behaviour that moves:KHR_texture_basisu(and, for a KTX2-mode lightmap,EXT_texture_webp) lands inextensionsRequired, so the GLB needs a basisu-capable viewer; a caller whose GLB must open in Blender or Unreal asks for the twins --web_delivery_texture_params(ktx2_fallback=True)(the policy's third override,None= policy like the other two), which the scene exporters offer as theKTX2 + PNG/JPEGTexture File Type. The preview deliverer's own override is gone (the policy says it now);drop_glb_texture_fallbacksstays the retrofit for a GLB built with twins. -
2026-09-12 -
MeshConvert.compact_glb_animationsdeletes the time inputs its collapsed channels stop reading. A clip rebuild gives each channel a time accessor of its own over a shared view, so rewiring a constant channel to the clip's two-key input stranded the old one: 15,843 unreferenced accessors (1.59 MB of JSON, each a validator UNUSED_OBJECT) in the production 4K GLB. The deletion is_release_animation_payload's, shared as_drop_orphaned_accessors(same renumbering, same refusal under extensions that hold accessor indices of their own). -
2026-09-12 -
ExportVerifierstates a declared extension requirement inglb_extensionsinstead of warning on it inglb_envelope. The envelope gate turned everyverify_glbnote into a WARN, including the "requires a reader supporting ..." prerequisite thatglb_imagesalready PASSes as the declared contract -- a standing warning on every WebP deliverable, and on every KTX2 one once the fallback twins went.verify_glb's own report is unchanged; the note has one definition (MeshConvert._requirement_note) that both read. -
2026-09-12 - the GLB build no longer makes FBX2glTF bake every shot twice (
file_utils/mesh_convert/fbx_media.py,.../fbx_file.py,.../glb_pipeline.py). FBX2glTF evaluates every node at every frame of every take, and a Maya take split writes each shot as its own stack beside the whole-timeline one -- which the clip rebuild then slices, discarding the converter's split takes.GlbPipeline.buildnow reads the FBX's ownfbx_takeschannel (FbxFile.user_properties) and, while a stack those names do not cover survives, converts a scratch copy without the declared takes (FbxMedia.drop_takes: each namedAnimationStackplus every layer/curve node/curve only dropped owners reach, their connections,Takesentries andDefinitionscounts; everything else byte for byte). Measured on a production assembly (18 shots, 2495 nodes): conversion 1377 s -> 537 s, converter input 491 -> 385 MB; the whole-timeline animation came out identical (1203/1203 channels, 19.4M values, NaNs in place), as did all 3388 mesh/skin accessors, the node TRS and the materials. A failure is a warning and the original is read. Behaviour that moves: if the clip rebuild later declines, the GLB carries the whole-timeline stack alone rather than the converter's split takes.FbxMedia.downsizeanddrop_takesnow share one tracked.partwriter. -
2026-09-12 - reduce-to-extremes math vectorized (
math_utils/_math_utils.py).fit_hermite_slopesbuilt its hold test and normal equations with a numpy call per SEGMENT (130 s of a production extremes pass); both are now whole-array (reduceat/bincount) and the Thomas solve runs on plain floats.reduce_samples' refinement finds each segment's worst sample in onereduceat. Output identical to 1.3e-16 relative over 300 randomized curves (holds, rounding, 3-3000 samples), 2.5x faster in isolation.keep_indicesout of ascending order now raisesValueErrorinstead of producing garbage. -
2026-09-12 -
MeshConvert.clip_spansbisects the sorted key times instead of filtering every key once per take: 23 s of an 18-take production export (millions of baked keys, two calls per export), now negligible. -
2026-09-12 -
TaskFactoryrecords_last_skipped_checksbeside_last_skipped_tasks. A caller that proceeds past a failed check (the exporters' override) must not report the checks the abort dropped as passed -- they never ran. -
2026-09-12 -
GlbPipeline.builddelivers the GLB beside the caller's FBX when a payload stage swapped the converter's input. With nodst,fbx_to_glbwrote beside whatever it read, so once the split-take strip (or a downsize) handed it a scratch copy, a production export's GLB landed in the temp dir and never reached the export folder. -
2026-09-12 -
MeshConvert.fbx_to_glbsends the FBX SDK's embedded-media extraction to a temp store of its own (--fbx-temp-dir, removed when the call returns; a caller's own--fbx-temp-dirinextra_argswins). The SDK extracted every embedded texture into<stem>.fbmbeside the input: 311 MB beside a production export in its synced folder, and 311 MB per export in the temp dir beside a pipeline scratch copy. Maya's own FBX export writes no.fbm(probed). -
2026-09-12 -
MapFactory.prepare_mapsreturns a parallel batch in the sets' own order. Results were collected in completion order, so withmax_workers > 1a caller iterating the batch got whichever order the workers finished in -- different from the serial branch, and from run to run. -
2026-09-12 - The embedded scene sidecar's texture map follows image renumbering.
prune_glb_unreferenced_texturesrenumbered images without the envelope'stexturesmap, and the highlight pass prunes AFTER that map is recorded: on a production GLB 28 of 35 references named the neighbouring image, their digests stamped against those wrong bytes soverify_glbpassed them. A pruned image's references now go (the envelope'svalidatecount follows), one that named no image is left forverify_glbto report, anddedupe_glb_imagespoints a dropped twin's references at its survivor. -
2026-09-12 - a user-typed NAME pattern, resolved once for every tool that offers one (
str_utils/_str_utils.py). A field that names one output file wants a grammar, and every tool that grew one grew its own: the Scene Exporter's Output Filename read an asterisk as a glob against the disk, its Version field read{tokens}, and neither could say what the other accepted.resolve_name_patternis the whole of it, composed from three primitives rather than written as one:expand_wildcardrewrites the bare wildcard into placeholder form,resolve_placeholderssubstitutes and reports,to_legal_filenamedrops what an OS will reject. The wildcard is POSITIONAL, which is the difference fromfind_str_and_format's asterisk -- that one is a mode flag, so it reads*_GEOas "suffix" and can express only one affix at a time; this one is a slot standing for the default value, soWIP_*_exportwraps a name in a single pass and a blank field and a bare*are the same thing. It is deliberately the ONLY wildcard: a name resolves to one value, so a match-anything glob has nothing left to mean, and every other filename metacharacter is illegal on Windows anyway. Diagnostics come back as data --unresolved,dropped,error-- rather than through a logger, because the two callers report them at different severities and one of them is a tooltip; the same call also hands back theexpandedform so a live preview resolves the very string the write will.name_pattern_contextvalues the tokens no host is needed for (date,time,user) and samples them ONCE, so a pattern using several cannot straddle a tick; a tool adds only what it alone can value.to_legal_filenameis the permissive counterpart toto_legal_name, which is an identity rule and would mangleasset-v2.herointoasset_v2_hero; this one removes only<>:"|?*, and reports each so a caller can say what it took. A pattern whose every character is dropped falls back to the default rather than returning empty -- a lone?would otherwise have a caller join nothing onto a directory and write a file that is only an extension. A malformed pattern (a lone brace) is legalized too: its braces read as literal text and its illegal characters are dropped and reported, where it came back verbatim for Windows to refuse at the write. Two fixed while adjacent.replace_placeholdersraisedIndexErrorout of the formatter on a POSITIONAL field ({}/{0}), which it has always done and which the Version pattern could already reach: this takes keywords only, so a positional field has no value and is now preserved like any other missing key -- every caller of this resolves text a USER typed into a pattern field, where a stray brace pair is a typo, not a reason to abort an export. Andfind_str_and_format'sreplace_prefixreturned the string untouched when there was no filter text to drop, wherereplace_suffixin the same position appended -- the asymmetry contradicted that method's own Note, soGEO_*with an empty filter now prepends the way*_GEOhas always suffixed.test_str+16. Full suite: 4559 passed. -
2026-09-11 -- a render-effect channel's colour is a RAMP with two ends, and each end carries its own default (
core_utils/color.py,file_utils/mesh_convert/glb_fades.py,.../_mesh_convert.py). The highlight channel is one keyable float and one colour, so its dim half was not a colour at all -- it was the absence of one, and an artist asking for the opposing half of a pulse had nothing to set.ColorStopsnames the two ends (hi,lo) as whatever string the consumer addresses a colour by, since the two sides of the glTF join spell the same concept differently -- Maya attribute names on one side, published track keys on the other. One object rather than two optional fields, because the stops are not independent: alowithout ahiis meaningless, and -- the part that would have shipped a silent regression -- the two ends need DIFFERENT fallbacks. A channel publishing no colour reads WHITE so the ramp still shows, which was the oldDEFAULT_COLOR; hand that same white to an unstated LOW stop and every asset authored before the stop existed inverts on its next export, pulsing from white down to its colour. The defaults now live beside the stops they belong to instead of as one module constant a caller picks, so there is no call site that can pick the wrong one._highlight_valueslerps between the two ends over the material's own emissive rather than scaling one, and the legacy shape falls out of it exactly: a low stop of black ISbase + hi * sample, which is whyColorStops.BLACKis what an unstated low stop resolves to.resolve()reads the shapes producers actually hold --None, a bare(r, g, b)meaning the high end alone, or one triple per stop -- so a producer written before a channel grew a second stop stays correct with no alias and no version check, and a malformed entry falls back rather than raising, because a colour is lookdev and refusing to publish a whole scene over one bad triple trades a wrong tint for no deliverable. The flat-versus-pair test is on the THREE NUMBERS, not onvalue[0]: sniffing the first element alone misreads[None, (r, g, b)]-- a pair whose high end was never published -- as a flat triple and hands the low colour to the high end. Breaking:PointerChannel'scolor_keyfield is nowcolor_stops(aColorStops), soPointerChannel(color_key=...)raisesTypeError(no caller outsideglb_fades.pyconstructs one);PointerChannel.color_keysurvives only as a deprecated read-through of the high stop, for one release.Coloralso gainedhsv/from_hsvf, both documented as a read-out rather than a working representation: the 8-bit store quantises and hue is undefined as value approaches zero, so a slider that round-trips a drag through them walks the hue off and returns WHITE from a blue dragged to black and back (measured). Anything that EDITS a colour has to keep its own float HSV state.test_color+15,test_mesh_convert+3 -- including the compatibility pin, which asserts a track with no dim stop ships byte-identical output to one that states black. Full suite: 4544 passed. -
2026-09-11 -- the WebXR preview asks before it records, and can burn in the shot DESCRIPTION (
net_utils/preview/scripts/playblast.js,net_utils/preview/viewer.html). The burn-in was a toggle sitting beside the record button, which is exactly the kind of state that gets left on and found in the movie afterwards -- and the annotation is drawn into the pixels, so there is no taking it out again. It is now asked at the moment the file is written:Export Playblastopens a prompt that states what it is about to write (clip, frame count, rate -- the clip is the picker's to choose and the rate is the deliverable's, so the prompt can only show them) and offers the two burn-ins. Answers are remembered for the tab, because a reviewer annotating one shot is about to annotate the next one the same way, and are SNAPSHOTTED onto the job at start: a recording is one thing, and half of it stamped is not a state this can reach. Cancel resolves to null and writes nothing, and a push landing while the prompt is up DISMISSES it -- every dialog this page shows is about the deliverable on screen, so a model swap invalidates the question; the page closes it itself rather than each script having to, and it resolves as a cancel, which is the safe answer to a question whose subject has gone. The new burn-in draws the note the DCC's Shots panel carries -- already inextras.animation_webasdescription, and the half of a shot record a reviewer watching the movie cannot otherwise see -- on the line ABOVE the name, so adding it pushes the name up rather than moving the frame counter off the edge the two corners are measured against. The two options are INDEPENDENT rather than nested, which is what lets the prompt ask them as two plain checkboxes with no dependent-field logic: neither ticked means no pad and nothing drawn, and the description alone lands on the foot of the frame. A shot stating no description records without one; holding a blank line open for it would be a burn-in that says nothing. Left text is now elided with an ellipsis against the counter's measured width -- a description is prose and can be longer than the frame, and text running off it reads as a corrupted burn-in rather than as a long note; the trim estimates proportionally first (the font is monospaced, so the estimate is usually exact) rather than costing a couple of hundredmeasureTextcalls on every frame. Two new pieces of viewer script API, both general rather than shaped for this one caller:viewer.showDialog({title, fields, confirm})is the page's ONE modal, resolving to{key: checked}or null -- a script asks for options through it rather than throughwindow.confirmfor the reason it adds buttons throughaddButtonrather thancreateElement, one look and one place -- and it takes the keyboard whole while open, since Space is a checkbox in there and would otherwise also pause the transport behind it, and leaves the page behind it inert until it closes (swallowing the shortcuts alone left Tab free to walk focus out to the clip picker, where an arrow key changed the clip the prompt names);viewer.descriptionAt()answers for whatever the model is posed at, on a single clip as well as on the sequence (unlikeshotAt, whose reader is the readout the picker already duplicates).test_preview_viewer_live+4, driving the prompt as a user does rather than reaching into the script's state: the description reaches the PIXELS, a shot with no description leaves the frame alone (the pair is what makes either meaningful), and a cancelled prompt writes no movie and starts no recording.