K3D-jupyter 3.1.1
A maintenance release, 54 commits. Almost all of it makes something that was documented, or plainly intended, do what it said; one thing is new, and it leads the list because it is the only thing here that was not possible before. It comes out of two review passes over the code after 3.1.0 plus a few user reports that arrived while those were running.
What changes for you
A volume can carry colour per voxel. A photographic or RGB-encoded scan measures colour directly — the Visible Human cryosections, an RGB24 NIfTI — and there was no way to show one: the dtype check cast uint8 to float32, and the browser uploaded the 4D shape as a one-channel texture of the wrong dimensions, which draws something rather than nothing. A 4D array of uint8 shaped [z, y, x, 3] or [z, y, x, 4] is now colour per voxel in volume, volume_slice and mip, and color_map / color_range are refused with a warning because there is nothing left for them to do.
The alpha is the only invention: colour cannot say where a ray should stop, so it comes from Rec. 709 luminance shaped by opacity_function, and the same luminance feeds the gradient the shader lights with. mip maximises that luminance and keeps the colour of the voxel that reached it. The path tracer has no medium for this — its density is one channel and its colour comes from a transfer function — so such a volume stays on the rasterised layer with a warning instead of being traced with the red channel as density.
One thing is worth knowing before reaching for it, and both the docs and the example say so: a volume is sampled trilinearly, so every surface has a rim where the texture fades in. A scalar field hides that, because the colormap turns whatever value the ray stops at into a full-intensity colour; here the value is the colour, so a ray stopping halfway up the rim paints a half-bright one. Measured on a white ball, a ramp rising from 0.02 renders it at 76 levels, the same ball with the ramp rising from 0.45 renders at 249, and with interpolation=False the low ramp renders at 255.
examples/volume_rgb.ipynb loads the Visible Human head from its NIfTI and ends with ten seconds of video — a film is an RGB volume nobody calls one, since (frames, height, width, 3) is already the shape a volume is indexed in.
Screenshots finally match the canvas. Anything not fully opaque came out darker by its own alpha — a mesh at opacity 0.5 over white read 191 instead of 255 — so grids and transparent objects were heavier in the file than on screen, with a dark fringe on every antialiased edge. This is why every reference image in the suite is rewritten here.
Picking names the right thing. click_callback / hover_callback returned an index that was almost never the point or face under the cursor: on 5000 points, 4996 were wrong; on 300 triangles, 295. Position was right, so only code that used the index noticed. Also fixed: clicks missing a scaled mesh, shader='dot' firing a scene unit away, hidden objects answering clicks, hover events arriving after the cursor left the canvas, and callbacks never reaching Colab.
The factories take the parameters their objects take. k3d.plot(mode='callback') raised TypeError although k3d.Plot(mode='callback') worked, and nine more plot parameters were in the same position: rendering_steps, colorbar_scientific, camera, camera_animation, clipping_planes, hidden_object_ids and the three slice_viewer_*. On the object side the same gap was silent: visible is a trait of every drawable and was a parameter of no factory, so k3d.points(pts, visible=False) landed in **kwargs, which only process_transform_arguments reads, and came back visible — with no error at all. click_callback and hover_callback went the same way on the objects that have them.
Parameters set before display() now apply. Ten of them — camera_up_axis, time, height, time_speed, fps_meter, minimum_fps, rendering_steps, axes_helper_colors, colorbar_scientific, custom_data — reached the browser only if you assigned them a second time after the plot was on screen, so k3d.plot(camera_up_axis='z') gave a free camera and k3d.plot(time=2.5) opened at 0. height is why a snapshot taken from the browser was always 512 px high, and plot.mode = 'manipulate' left every object without its gizmo.
Snapshots load again. Any scene with a texture, an STL or a textured mesh wrote fine and then raised TraitError on read — and pickle.dumps(plot) failed the same way. A snapshot dropped onto a plot now replaces the scene (as the docs say) instead of stacking on it, and opens at its own time.
Camera locks hold. camera_no_rotate/zoom/pan were ignored by the orbit camera, by the slice viewer, and by everything after any controls rebuild. A rebuild also threw away the orbit target — setting camera_up_axis on a CT volume in [0, 512] swung the view to the origin — plus your zoom and pan speeds.
Time series. float16 interpolated its bit patterns (100 and 1000 met at 322); keyframes were overwritten by playback, so returning to time 0 no longer showed frame 0; scalar colours blended through wrong hues; animated vertices on thick lines did nothing; animated model_matrix crashed get_auto_grid.
Volumes. transform.bounds, an animated model_matrix or a gizmo drag left the volume blank. Setting mask before mask_opacities never turned the mask on. color_range rescaled only the first channel of a two-channel slice. Slices sampled half a texel off. opacity on a volume_slice did nothing between 0 and 1. The "Refresh light map" button threw unless shadows were on.
Voxels. Sparse grids whose dimensions differ (space_size=[4, 2, 3]) had voxels overwrite each other and phantoms appear in the wrong colour. Chunk seams were drawn twice — visible as darker outlines every 96 voxels. Edits and paints on a chunk boundary were dropped.
Points and lines. point_sizes=0 drew the largest sphere. Moving a point cloud left it culled from angles that no longer matched the data. A NaN separator cost each run its last point, and two-point runs never drew at all.
Other objects. Marching cubes attribute was sampled in the wrong place — four vertices in five clamped to one face of the texture. Translucent surfaces and STLs never entered the depth-peeling path, and dragged everything else onto the slow path with them. One undecodable texture hung the entire load with no error anywhere. Malformed LaTeX in a label took down every object in the same sync. Vector labels sat half a label width off centre.
Grid and labels. A hand-set grid was discarded the moment anything forced a rebuild. Axis labels and k3d.text did not appear after loading a larger scene or resizing until you moved the camera.
Legend and transfer function editor. The colour bar threw on an object with no colour range, showed a reversed color_range backwards, printed zeros for narrow ranges, and ignored colorbar_scientific until its next rebuild. In the TF editor a dropped colour came out grey, and color_map / opacity_function set from Python were ignored until you dragged something.
Cinematic. The preview now shows what the screenshot will hold: rasterised layers no longer vanish when accumulation starts, the grid is gone from both, and a screenshot no longer ghosts into later frames. Colormapped meshes no longer lose their colour (or throw) depending on object order, cinematic_denoise no longer dies for the session after one failed BVH build, and clipping_planes — which has never worked here and cannot without patching the tracer — says so instead of being silently ignored.
Memory. Closing a plot, re-running a cell, replacing a large point cloud, switching the environment map or editing voxels all leaked; a plot could keep gigabytes in the tab after Python let go, and dead views still rendered full frames on every fetch_screenshot.
Docs. Seventeen factory pages (points, volume, voxels, text, vectors, texture, …) rendered as a heading and nothing else; all of them now document every parameter, taken from the traits, so the two stay in step.
Packaging. The sdist packed the whole working tree — 462 MB here, 425 MB of it a CT scan sitting in the root — and would have shipped it to PyPI. Now 11.6 MB. Merging also closes security alerts #153 and #154 (js-yaml, morgan).
Technical details
Renderer / screenshots
de25f16— premultiplied vs straight alpha: the convention is a uniform ontoneBlit/rawBlitnow, ascomposite.fragment.glslalready does for the peeled path.c1a4881— compass cut by clipping planes; AO divisor from CSS size instead of the drawing buffer; advanced AO buffer left multiplied into cinematic previews; peel budget not reset ondepth_peels;camera_up_axisnever respun the environment.b2e8938— render queue clearedrenderingPromiseoff the tail, so two renders ran side by side and the second restoredcontrols.enabled=false.4af3817— grid overwritten outsidegrid_auto_fit;camera.frustumstale after a scene or canvas resize; KaTeX throwing synchronously before the loader's promise exists.
Picking / callbacks
294d0e7,5b44501— three-mesh-bvh sorts the index buffer in place; mesh BVH built withindirect, points keep the sort. Pick radius divided by scale twice;dotthreshold in world units;traverse→traverseVisible;pointerleaveclears the cursor.
Objects
0fa4dcf—sizes[i] || 1.0; per-point size vs multiplier mismatch between shaders; impostor depth from the uniform;InstancedMeshbounds cached across an update; NaN handling off by one, and non-finite neighbours in MeshLine.3f3916c— Surface/STL materials unprepared for MRT peeling; STLCOLOR=alpha; missingonerror;minFilteroverwritten;Texture.opacityundefined; >8 slice planes, section outline attribute.b683c24,dc159a7— raymarch box read from uniforms onlycreate()wrote;mask_opacitiesguard always true; per-channel bounds; half-texel plane offset; slicetransparentnot recomputed; light-map button.1b7bc9f,0175adc,5a15b19— sparse keyx + y·w + z·hnot a bijection; seam plane emitted by both chunks;chunk.offsetread as.x/.y/.z; fifth arg ofset()means opposite things in the two maps.5a15b19,53f0d84— MC texcoordpos + 0.5→pos / size; flat attribute → 1×1×N overMAX_3D_TEXTURE_SIZE, now refused;commonUpdaterecomputingtransparent/sidefrom too little and resetting baked scale.edfd6e1—referencePointvsreference_point.
Python
96fc1af,7a339fc,3582f98— unsorted transformed corners;model_matrixas a dict; Surface/Texture/Text boxes; platonic winding;check_attribute_color_rangechannels;Unionbranch order inTimeSeries(and its default); traittypes casting before.valid(); float32sqrt(1-cos²); big-endian dtype key;push_datawithoutowner;vtk_poly_datastrips;bounding_corners; strided volume vs the headless fingerprint.b2acc86,2f64f80,3c29723—Bytestraits round-tripped as uint8; callbackTruerestored as the value;snapshot_typeand four traits missing from the registry; ten traits missing from the widget constructor literal;[TIMESTAMP];on_msgdouble dispatch.e3fc055,c6739e6,70947cc— objects before parameters, in both the headless sync andsetSnapshot; headless param map keyed by the wrong names;deepcopyof a bound method inside the Flask route; memoryview compared against itself.59b323d— gizmo returningmodel_matrix × offset; hidden groupgetObjectById;compressed_datanot packed; removal during load; GUI rebuilt against a destroyed one; detach dropping DOM overlays andchunks_ids.
Cinematic
f45d3b1,0175adc— raster layer never built by the interactive loop; grid in the preview only; screenshot not aborting the loop;uvitemSize 1 vs 2 throughStaticGeometryGenerator;syncMaterialstransparency;texture_textfrozen at build time;lastDenoisesurviving a tracer rebuild; two alpha conventions in one buffer; zero-width range claimed by the proxy;clipping_planeswarning; material fast path blocked byid/typekeys.
Colour per voxel
10c645c— a trailing axis of 3 or 4 means colour, anduint8is kept rather than cast;USE_RGB_VOLUMEin the volume, lightmap, slice and mip shaders; luminance feeds both the alpha ramp and the shading gradient, sogetMaskedVolumestays one function; RGB is uploaded as RGBA because WebGL2 aligns texture rows to four bytes and RGB8 shears an image whose width is not divisible by four. Two things fixed on the way:typedArrayToThreehad no entry forUint8Arrayand returned null, which no volume had ever hit, andk3d.mipof a 4D array cast it to float32 and uploaded a one-channel texture of the wrong dimensions.
Factory signatures
c747874— everysynctrait of a class, less the ones the factory derives itself (model_matrixfrom the transform arguments, the geometrystlandvtk_poly_dataread out of their input), has to be a parameter of the factory that builds it. The test states that rule rather than the cases, and fails five ways against the old code.
Legend / GUI / memory / build
0e3ea7d—hasRange; reversed range; decimals from the step's spelling; zero-width range; cache key;mergeColorMapWithOpacitystride;draggableElementinitialised toundefined.4a142eb— ~60 model listeners never removed; PMREM targets; chunk geometry and its BVH.b3cdc2e,2ed638f— sdist allowlist;_version.pyreading the wrongpackage.json; missing reference compared against empty RGBA (so a blank render passed);compare()threshold documented (35215·t²≈ 52 levels at the default 0.2); conftest undoing the driver's timeouts;ruffpinned in CI.
Reference images
All 416 are rewritten and nine are new. The bulk is the alpha fix — every image has antialiased edges; 134 of 319 tests failed against the old set and the previous full run was green, so the whole difference is that change.
About twenty moved again because the old image was the record of a bug: twelve marching cubes references held the clamped texture coordinate (six of them advanced renders that had been falling back to the simple image and need their own now); two cinematic surfaces held the uv stride bug — a banded rainbow where the raster reference of the same plot is a smooth ramp; four vector labels moved by half a label width; points_mesh_sizes loses the sphere that was asked to have size 0; the voxel scenes moved with the chunk seam (voxels_wireframe by 5 px, the rest below threshold, reproduced byte for byte on a second run); the volume slices moved with the half-texel offset.
The nine new ones cover colour per voxel: a slice, a march and a maximum-intensity projection of the same ball, plus the slice viewer and the renderers that differ enough to need their own. Their backgrounds and opacity ramps were chosen by measurement rather than taste — the first attempt at the march was a 20-level object against a 52-level comparison threshold, and would have passed even if the ball had vanished. Each is byte-identical across two independent acceptance runs, cinematic included. cinematic/rgb_volume_slice is deliberately absent: cinematic does not draw slices, and a blank page is also what a failed render produces.
Verification
Full suite in the Docker image: 338 passed in 23:04. ruff over the tracked tree and npx grunt codeStyle both clean. examples/volume_rgb.ipynb runs end to end under nbconvert.
Two tests removed — the heap-growth check (needed --enable-precise-memory-info, which no run passes, so it never executed anywhere) and the marching cubes attribute test, at the maintainer's request. Nineteen added: bounding boxes, camera locks, custom_data, headless sync order, float16 time series, a cinematic material-only update, the factory-signature rule, and eleven for colour per voxel — seven that ask questions with answers known before rendering, and four that pin the picture.
🤖 Generated with Claude Code