github K3D-tools/K3D-jupyter v3.1.1
3.1.1

3 hours ago

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 on toneBlit / rawBlit now, as composite.fragment.glsl already 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 on depth_peels; camera_up_axis never respun the environment.
  • b2e8938 — render queue cleared renderingPromise off the tail, so two renders ran side by side and the second restored controls.enabled=false.
  • 4af3817 — grid overwritten outside grid_auto_fit; camera.frustum stale 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 with indirect, points keep the sort. Pick radius divided by scale twice; dot threshold in world units; traversetraverseVisible; pointerleave clears the cursor.

Objects

  • 0fa4dcfsizes[i] || 1.0; per-point size vs multiplier mismatch between shaders; impostor depth from the uniform; InstancedMesh bounds cached across an update; NaN handling off by one, and non-finite neighbours in MeshLine.
  • 3f3916c — Surface/STL materials unprepared for MRT peeling; STL COLOR= alpha; missing onerror; minFilter overwritten; Texture.opacity undefined; >8 slice planes, section outline attribute.
  • b683c24, dc159a7 — raymarch box read from uniforms only create() wrote; mask_opacities guard always true; per-channel bounds; half-texel plane offset; slice transparent not recomputed; light-map button.
  • 1b7bc9f, 0175adc, 5a15b19 — sparse key x + y·w + z·h not a bijection; seam plane emitted by both chunks; chunk.offset read as .x/.y/.z; fifth arg of set() means opposite things in the two maps.
  • 5a15b19, 53f0d84 — MC texcoord pos + 0.5pos / size; flat attribute → 1×1×N over MAX_3D_TEXTURE_SIZE, now refused; commonUpdate recomputing transparent/side from too little and resetting baked scale.
  • edfd6e1referencePoint vs reference_point.

Python

  • 96fc1af, 7a339fc, 3582f98 — unsorted transformed corners; model_matrix as a dict; Surface/Texture/Text boxes; platonic winding; check_attribute_color_range channels; Union branch order in TimeSeries (and its default); traittypes casting before .valid(); float32 sqrt(1-cos²); big-endian dtype key; push_data without owner; vtk_poly_data strips; bounding_corners; strided volume vs the headless fingerprint.
  • b2acc86, 2f64f80, 3c29723Bytes traits round-tripped as uint8; callback True restored as the value; snapshot_type and four traits missing from the registry; ten traits missing from the widget constructor literal; [TIMESTAMP]; on_msg double dispatch.
  • e3fc055, c6739e6, 70947cc — objects before parameters, in both the headless sync and setSnapshot; headless param map keyed by the wrong names; deepcopy of a bound method inside the Flask route; memoryview compared against itself.
  • 59b323d — gizmo returning model_matrix × offset; hidden group getObjectById; compressed_data not packed; removal during load; GUI rebuilt against a destroyed one; detach dropping DOM overlays and chunks_ids.

Cinematic

  • f45d3b1, 0175adc — raster layer never built by the interactive loop; grid in the preview only; screenshot not aborting the loop; uv itemSize 1 vs 2 through StaticGeometryGenerator; syncMaterials transparency; texture_text frozen at build time; lastDenoise surviving a tracer rebuild; two alpha conventions in one buffer; zero-width range claimed by the proxy; clipping_planes warning; material fast path blocked by id/type keys.

Colour per voxel

  • 10c645c — a trailing axis of 3 or 4 means colour, and uint8 is kept rather than cast; USE_RGB_VOLUME in the volume, lightmap, slice and mip shaders; luminance feeds both the alpha ramp and the shading gradient, so getMaskedVolume stays 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: typedArrayToThree had no entry for Uint8Array and returned null, which no volume had ever hit, and k3d.mip of a 4D array cast it to float32 and uploaded a one-channel texture of the wrong dimensions.

Factory signatures

  • c747874 — every sync trait of a class, less the ones the factory derives itself (model_matrix from the transform arguments, the geometry stl and vtk_poly_data read 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

  • 0e3ea7dhasRange; reversed range; decimals from the step's spelling; zero-width range; cache key; mergeColorMapWithOpacity stride; draggableElement initialised to undefined.
  • 4a142eb — ~60 model listeners never removed; PMREM targets; chunk geometry and its BVH.
  • b3cdc2e, 2ed638f — sdist allowlist; _version.py reading the wrong package.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; ruff pinned 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

Don't miss a new K3D-jupyter release

NewReleases is sending notifications on new releases.