github K3D-tools/K3D-jupyter v3.0.2
3.0.2

2 hours ago

K3D-jupyter 3.0.2

3.0.0 was the release with the three big stories in it. Everything since is a patch on top of it, but 3.0.1 shipped four real features and went out with a release note that said 3.0.1 by @artur-trzesiok and nothing else - so this page covers everything since 3.0.0 and marks which of the two releases each item arrived in. Nothing here is breaking: no trait was renamed, no default changed, and the lighting model is untouched.

If you are on 3.0.0, the two things worth knowing before reading further are that glTF export and point picking exist now (3.0.1), and that online and inline snapshots taken with 3.0.0 or 3.0.1 are broken and need re-taking (fixed in 3.0.2).


glTF export (3.0.1)

A plot used to leave K3D either as a PNG or as a stand-alone HTML viewer: a picture, or a whole application. Neither of them is a model. glTF export produces the third thing - the geometry itself, as a binary .glb that opens in Blender, MeshLab, Windows 3D Viewer or a slicer.

plot.fetch_gltf()          # asks the browser to build it
# next cell:
from base64 import b64decode
open('scene.glb', 'wb').write(b64decode(plot.gltf))
  • plot.gltf carries the file base64-encoded, the same convention plot.screenshot already uses for PNG. @plot.yield_gltfs turns the round trip into a generator if you want it in one cell.
  • An Export glTF button in the panel saves the same file straight from the browser, with no kernel behind it - so it works inside a stand-alone snapshot too. Somebody who was sent a snapshot can still pull the model out of it.
  • For scripts and CI, k3d_remote.get_gltf() is the synchronous equivalent; in JS, K3DInstance.getGLTF() resolves to an ArrayBuffer.
  • Every route goes through a browser, and that is not an implementation detail: for most object types Python holds only the input - a scalar field, a voxel array, a height map - and the triangles are produced by the renderer. mesh is the one exception that carries its own vertices.

What the format can hold. glTF describes triangles and PBR materials. An object whose shape exists only while its shader runs has nothing to hand over, so it is left out rather than exported as the proxy geometry that shader consumes - a volume would otherwise arrive as a plain cube.

Exported: mesh, surface, stl, marching_cubes, voxels, sparse_voxels, voxels_group, texture built from an image, points with shader='mesh', line and lines with shader='mesh' (tubes) or shader='simple', and the arrowheads of vectors and vector_field.

Left out: volume, mip, volume_slice, texture built from an attribute, points with shader '3d', 'dot' or 'flat', line/lines with shader='thick', text, text2d, label, texture_text, and the shafts of vectors and vector_field. Whatever was skipped is named in the browser console rather than silently missing, and switching a points or lines object to shader='mesh' is enough to bring it into the export.

Objects hidden with visible = False are skipped as well, so the panel doubles as a way of choosing what goes into the file. The grid, axes, lights and color legend are not part of the model and never travel.

Docs: glTF export, plus examples/gltf_export.ipynb and a functional test suite that opens what the exporter wrote.

Point picking (3.0.1)

k3d.points can now report the point under the cursor:

plot.mode = 'callback'

def on_pick(params):
    print(params['index'], params['position'], params['distance'])

points.hover_callback = on_pick
points.click_callback = on_pick

Points derives from DrawableWithCallback, so it gains the same click_callback and hover_callback traits the voxel objects have had. Attaching either one builds a BVH over the positions, so picking stays fast on clouds far larger than an example; clearing both disarms picking and releases it. Both shaders are pickable and the payload is identical - 3d draws camera-facing impostors, mesh draws real instanced spheres - and switching the shader on a live object re-arms it.

Two things had to be fixed for this to be usable at all. The pick radius now follows point_size in the mesh case: the raycaster read the instance scale, which is relative to the icosahedron the loader has already baked point_size into, so the threshold was off by that factor and whichever point sat nearest the camera won every pick. And to_json now recognises a callable, which is what lets the "a handler is attached" flag reach the browser on the snapshot path as well as the widget one.

examples/points_callback.ipynb highlights the point under the cursor by editing the cloud's own colors; test_functional_points_picking.py aims the raycaster at each point in turn and checks that it picks itself.

Depth peeling costs one pass per layer (3.0.1)

Peeling drew the scene twice per layer: once with an override material to capture the layer's depth, once for its colour. With multiple render targets the patched materials write their own depth into attachment 1 while colour goes to attachment 0, so a layer is one scene render. A scene at depth_peels = 4 went from ten full scene renders per frame to five.

It is all-or-nothing per frame, deliberately: WebGL2 shares blend state across attachments, so one unpatched material would blend and never write attachment 1, and the frame falls back to the two-pass path if any visible object has one. Volumes are exempt - they are hidden for the geometry passes anyway.

On top of that, the peel count adapts. An empty layer makes every deeper one empty too, so the renderer probes the deepest two layers with occlusion queries and shrinks the budget while both come back empty, keeping one known-empty layer as headroom. Queries answer a frame late, so this converges over a couple of frames rather than instantly. It applies only to a full-frame render: a screenshot, a rendering_steps strip and a volumeSides quadrant all peel the full count, because a partial region would otherwise impose its own depth complexity on the rest of the frame and an exported image has to be exact.

rendering_steps and volumeSides in the same pass

  • Peel targets are sized to the region the composite actually lands in. With rendering_steps > 1 that is one strip, and a full-frame target held it stretched and got point-sampled.
  • The camera view offset goes on the camera being rendered, which in volumeSides mode is one quadrant rather than the main camera.
  • The fixed 50 ms wait between chunks became one macrotask, so a multi-step render is bounded by its work instead of by a sleep.

Voxel faces merge where they never could (3.0.1)

Greedy meshing merges two faces when their mask cells compare equal. For a face between two transparent labels the mask held a two-element JavaScript array, and array references are never equal - so those faces never merged, and the numeric mask was carrying boxed objects besides. Both labels now pack into a single negative number, so the comparison is numeric and the merge happens. Voxel scenes with transparency draw slightly fewer, larger quads than in 3.0.0; five reference images moved by that amount.

Smaller download (3.0.1)

KaTeX ships each of its 20 fonts as woff2, woff and ttf, and css-loader was inlining all three into the bundle. woff2 is first in every src list and covers every browser that has WebGL2, so the other two are now filtered out.

3.0.0 3.0.1
standalone.js on npm 3.66 MB 2.64 MB
wheel on PyPI 7.07 MB 5.12 MB

The wheel carries the frontend twice - the widget module and the snapshot bundle - which is why it saves about twice as much.

volume_slice takes two channels (3.0.2)

plot += k3d.volume_slice([channel_a, channel_b], color_map=bivariate_lut)

The bivariate path had been in the JS since it landed: VolumeSlice.js branches on Array.isArray(config.volume), substitutes the channel count into both shaders and builds low/high/volumeTexture/volumeSize per channel, colorMap.js dispatches a 2D colormap, and VolumeSlice's color_range carries no maxlen where Volume's and MIP's do. Every piece was in place except a way to get a list of arrays through the trait.

volume was TimeSeries(Array()), and traittypes' Array() validates a list by coercing it, so [a, b] arrived in the browser as one (2, N, N, N) object. That put it in the single-channel branch: one channel, a 1D colormap, and the shape read off a four-element tuple, so two 8-cubed channels came out as an 8x8x2 texture. A wrong picture, not an error.

The trait is now TimeSeries(ListOrSingle(Array())). ListOrSingle is the mirror of the existing SingleOrList and both have to exist, because Union takes the first trait that validates: Array() swallows a list by coercion where Unicode() does not. The wire format needed nothing - to_json already walked lists - but check_attribute_color_range now returns two bounds per channel and passes a 2N-length range through untouched, since the browser reads color_range[2 * i] and [2 * i + 1] and a two-element range would have left the second channel's uniforms NaN. Every channel is held to the same float16-or-float32 rule as a single volume, and opacity_function now warns that it is ignored for a multi-channel slice instead of disappearing without a word - the 2D colormap writes a fixed alpha, so there is no channel left to apply it to.

The ceiling is two, and the shader says so. Raising it to N means per-channel 1D colormaps and additive compositing instead of a joint LUT, which is a different change. volume and mip remain single-channel.

online and inline snapshots point at K3D again (3.0.2)

get_snapshot() substitutes [VERSION] into the unpkg URL for standalone.js, and it read that from self._view_module_version. Nobody touched the line; the anywidget migration changed what it returns. As an AnyWidget, Plot reports anywidget's own version range, so since 3.0.0 every online and inline snapshot has asked for

https://unpkg.com/k3d@~0.9.*/dist/standalone.js

which is not a version. The bundle never loads and the snapshot renders nothing.

Scope: full is the default snapshot_type and inlines the bundle, so it never reads [VERSION] and was never affected. Only explicitly requested online and inline are broken, in both 3.0.0 and 3.0.1. Our own docs gallery uses inline and kept working, which is why this went unnoticed - conf.py preloads standalone.js into every page, so require(['k3d']) resolves before the bad URL is ever reached. Anywhere else, it fails.

The fix is the module-level version this file already imported. test_snapshot_cdn_url.py pins both CDN templates to the package version and pins that full never references unpkg at all; there was no coverage of get_snapshot before, which is the actual reason a silent URL change survived two releases.

Other fixes

  • Voxel edit mode raycasts a stale BVH (3.0.1). A remeshed chunk arrived without the BVH addChunk had given it, and edit mode raycasts on every pointer move. The rebuild now regenerates it, and disposes the materials the rebuilt mesh brought with it.
  • A shader program compiled for the other pipeline (3.0.1). customProgramCacheKey replaces three's default, which is derived from onBeforeCompile, so every input that changes the injected shader has to be in the key - and whether depth peeling is on was not, for points and marching_cubes.
  • array_to_json stopped copying every array on the wire (3.0.1): ravel instead of flatten, contiguity having been ensured a few lines above.
  • k3d.platonic is reachable from a plain import k3d, without importing the submodule by hand (3.0.1).
  • The K3D.Voxels rebuildChunk console.log is gone (3.0.1), as is the obsolete TorusKnot provider object.
  • minimatch is pinned past its ReDoS advisory (3.0.1).
  • Six invalid escape sequences in example notebooks (MathJax labels) and two bare except: clauses that were swallowing KeyboardInterrupt (3.0.2).

Documentation

  • A new snapshots page (3.0.2). /user/gltf.html existed while /user/snapshots.html was a 404, so the export people actually ask about had less written about it than the one they do not. It covers snapshot_type and what the three kinds of file are for, fetch_snapshot/yield_snapshots, get_binary_snapshot/load_binary_snapshot, and additional_js_code. Two things in it were written down nowhere: the plot area is a drop target - .html loads a snapshot over the current scene, .stl adds a mesh, anything else is read as a binary snapshot - and the CDN-backed types bake the producing version into the unpkg URL, which is the same trap that hid the [VERSION] bug for two releases.
  • A new glTF page (3.0.1).
  • The streamlines GIF on the README and the docs front page is a headless 60-frame turntable now (3.0.2): 10.0 MB down to 2.7 MB, seamless at the loop point because 360 degrees over 60 frames means the last frame is the first, and it finally shows the Inferno colouring and the wireframe shell that the gallery page it advertises actually has. The other four assets were measured rather than assumed - ffmpeg cannot re-encode them smaller, because they already carry inter-frame optimisation that a re-quantisation destroys.
  • Links to k3d-jupyter.org unfurl as a card instead of a bare blue URL (3.0.2): meta descriptions, og:*, twitter:card, canonical URLs, sitemap.xml and a robots.txt that was a 404. The card itself is a dark render of the streamlines showcase, so the docs, the README and the anywidget gallery entry all advertise the same scene.

Packaging metadata (3.0.2)

PyPI returned null for summary, author and project_urls on every release, 3.0.1 included: four fields were declared dynamic while only the version hook was configured, so hatchling had nothing to resolve them from and emitted them empty. They are static values now, with homepage and bugs.url corrected - they had been a git clone URL and that same URL with /issues glued on.

  • Classifiers go from 8 to 16. The eight carried none of what a scientific visualisation library gets found by: no Topic :: Scientific/Engineering :: Visualization, no Topic :: Multimedia :: Graphics :: 3D Rendering, no Intended Audience, no Development Status.
  • The licence is a PEP 639 SPDX expression, so PyPI renders a licence chip instead of the entire MIT text as a metadata blob. The text still ships, at dist-info/licenses/LICENSE.txt. This moves the hatchling floor to 1.27.
  • The npm package no longer claims the keyword jupyterlab-extension - since 3.0.0 there is no extension to install or enable.
  • A CITATION.cff with the concept DOI, so GitHub renders a "Cite this repository" button. RELEASE.md gains the step it never documented: the GitHub Release itself, which is what the Zenodo webhook listens for. It never backfills, which is why the archive sat at 2.12.0 from February 2022 while releases kept shipping.
  • binder/ and the root postBuild are deleted rather than fixed. postBuild ran jupyter labextension install, which does not exist in JupyterLab 4, so its only remaining effect was that anyone pointing mybinder.org at this repo got a failed build attributed to K3D. A working Binder configuration is worth having and is not this release.

Dependencies

Runtime dependencies are unchanged since 3.0.0 - three 0.185.1, three-mesh-bvh 0.9.14, three-gpu-pathtracer 0.0.24, katex 0.18.5, lil-gui 0.21.0, fflate 0.8.3, anywidget >= 0.9.13, Python >= 3.9. What moved is everything around them:

  • Python extras actually work now. Five declarations nothing used are gone (pytest-notebook, pytest-cov, webdriver_manager, mdutils, jupyter_sphinx), and three packages k3d/test imports were declared in the wrong place or not at all (vtk, scikit-image, pillow) - so pip install -e .[dev] followed by pytest failed at collection for anyone who followed the extra's name. pixelmatch is pinned, for the same reason Chrome is: it is the function that decides whether a visual test passes.
  • The root package.json declares no dependencies. It mirrored twelve of the thirteen runtime dependencies from js/package.json, had already drifted out of sync, and none of it was reachable from the build - which nonetheless ran npm install in the repo root on every wheel build, for 119 packages. One line stopped this being a pure deletion: docs/conf.py copied require.js out of the root tree, and now takes the byte-identical copy from js/.
  • Nine dead devDependencies dropped from js/, each verdict put through an attempt to refute it, taking 1045 lines out of the lockfile; js/yarn.lock goes with them, having duplicated the npm lockfile while nothing in the build, the workflows or the image runs yarn. css-loader 7, style-loader 4, grunt-webpack 7, webpack 5.110.3 and webpack-cli 7 in the same pass, each verified by building rather than by reading a changelog.
  • fast-uri to 3.1.7, clearing four advisories in the build toolchain. Worth stating plainly: it never reaches a published artefact - zero occurrences in standalone.js and widget.mjs - and the bundles come out byte-identical, so the exposure was build-time only.
  • Dependabot is grouped and monthly, with three held majors and the reason for each recorded, so "no open pull requests" is not achieved by re-opening the same one every month.

Build and test infrastructure (3.0.2)

None of this changes what K3D draws, but one item explains the reference images in the diff.

The Chrome pin was not real. The image installed a pinned Chrome for Testing and left google-chrome-stable_current_amd64.deb installed, so it held two browsers and the selenium version decided which one ran. Moving the image to Python 3.12 on Debian trixie brought selenium 4.48, which picked the floating build - three patches away from the pinned chromedriver - and the first suite run produced 183 failures in 19 seconds. There is one browser in the image now. This was never specific to Python: a selenium bump alone would have started rendering the references with a browser nobody chose.

With the pin fixed, 261 of 268 tests passed unchanged and the seven failures were all text - labels, text, text2d, texture_text - which is freetype 2.10.4 to 2.13.3 and nothing else. Raw differences run 73 to 3110 pixels out of 921600; with the suite's own threshold pixelmatch sees 0 to 18, and with anti-aliasing detection on it sees zero almost everywhere. The whole change is glyph edges. Twelve reference images are regenerated for it.

  • The base image was python:3.9.16-slim, a patch from December 2022, and Python 3.9 reached end of life in October 2025. Lifting that ceiling moved 68 package versions in the image, including anywidget 0.9.21 to 0.11.0, numpy 2.0 to 2.5, pillow 11 to 12 and msgpack 1.1 to 1.2.
  • The suite runs in 42:08 where it took 48:02, because pixelmatch 0.4.0 short-circuits byte-identical images. That version is pinned too, as are ruff and the visual comparator.
  • eslint 8.57.1 to 10.9.1 with flat config, .eslintrc.js replaced by eslint.config.js, and airbnb-base consumed through FlatCompat rather than transcribed - a first attempt at inlining its rules lost about 51 of them, including no-undef and no-unused-vars. Verified by comparing resolved configs rather than by the run being green: 223 enabled rules before, 223 after, 0 lost, 0 added. "The gate passes" and "the gate stopped checking" look identical from the outside.
  • The ruff gate is ruff check . rather than a list of directories, so docs/ and tools/ are inside the config they were always covered by. 68 findings in docs/, including ten # noqa comments that had outlived their rules - exactly the rot RUF100 was selected to catch, which survived because the files it lived in were never scanned.
  • GitHub Actions to current majors, checked input by input; codeql-action v4 ahead of the December 2026 deprecation of v3.
  • The performance harness rotates which bundle is measured first. A fixed order was worth 5-25% on GPU-bound scenes and it is the machine, not the code: forty probes of one unchanged bundle drifted from 27.6 to 29.1 ms and read 64.8 ms cold against 27.7 warm, because a laptop GPU drops its clocks under sustained load and whoever is measured first reads the coolest one. Measuring the same bundle twice under two names did not catch it - both copies sat behind the leader, both already hot, so they agreed with each other and with nothing else. Each bundle now leads once and the report takes the middle pass.

Upgrading from 3.0.0 or 3.0.1

  • Nothing breaks. No trait was renamed, no default changed, and the lighting model is the same as in 3.0.0.
  • If you took an online or inline snapshot with 3.0.0 or 3.0.1, re-take it - the file points at a URL that does not exist. full, the default, is fine.
  • Voxel scenes with transparency draw slightly fewer, larger quads than in 3.0.0, so a pixel-exact comparison against a 3.0.0 render of one will differ.
  • k3d.points clouds pick themselves now once a callback is attached and plot.mode is 'callback'; nothing happens to a cloud without one.

Full changelog: v3.0.0...v3.0.2

Don't miss a new K3D-jupyter release

NewReleases is sending notifications on new releases.