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.gltfcarries the file base64-encoded, the same conventionplot.screenshotalready uses for PNG.@plot.yield_gltfsturns the round trip into a generator if you want it in one cell.- An
Export glTFbutton 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 anArrayBuffer. - 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.
meshis 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_pickPoints 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 > 1that 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
volumeSidesmode 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
addChunkhad 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).
customProgramCacheKeyreplaces three's default, which is derived fromonBeforeCompile, so every input that changes the injected shader has to be in the key - and whether depth peeling is on was not, forpointsandmarching_cubes. array_to_jsonstopped copying every array on the wire (3.0.1):ravelinstead offlatten, contiguity having been ensured a few lines above.k3d.platonicis reachable from a plainimport k3d, without importing the submodule by hand (3.0.1).- The
K3D.Voxels rebuildChunkconsole.logis gone (3.0.1), as is the obsoleteTorusKnotprovider object. minimatchis pinned past its ReDoS advisory (3.0.1).- Six invalid escape sequences in example notebooks (MathJax labels) and two bare
except:clauses that were swallowingKeyboardInterrupt(3.0.2).
Documentation
- A new snapshots page (3.0.2).
/user/gltf.htmlexisted while/user/snapshots.htmlwas a 404, so the export people actually ask about had less written about it than the one they do not. It coverssnapshot_typeand what the three kinds of file are for,fetch_snapshot/yield_snapshots,get_binary_snapshot/load_binary_snapshot, andadditional_js_code. Two things in it were written down nowhere: the plot area is a drop target -.htmlloads a snapshot over the current scene,.stladds 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.xmland arobots.txtthat 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, noTopic :: Multimedia :: Graphics :: 3D Rendering, noIntended Audience, noDevelopment 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.cffwith the concept DOI, so GitHub renders a "Cite this repository" button.RELEASE.mdgains 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 rootpostBuildare deleted rather than fixed.postBuildranjupyter 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 packagesk3d/testimports were declared in the wrong place or not at all (vtk,scikit-image,pillow) - sopip install -e .[dev]followed bypytestfailed at collection for anyone who followed the extra's name.pixelmatchis pinned, for the same reason Chrome is: it is the function that decides whether a visual test passes. - The root
package.jsondeclares no dependencies. It mirrored twelve of the thirteen runtime dependencies fromjs/package.json, had already drifted out of sync, and none of it was reachable from the build - which nonetheless rannpm installin the repo root on every wheel build, for 119 packages. One line stopped this being a pure deletion:docs/conf.pycopiedrequire.jsout of the root tree, and now takes the byte-identical copy fromjs/. - Nine dead devDependencies dropped from
js/, each verdict put through an attempt to refute it, taking 1045 lines out of the lockfile;js/yarn.lockgoes 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-urito 3.1.7, clearing four advisories in the build toolchain. Worth stating plainly: it never reaches a published artefact - zero occurrences instandalone.jsandwidget.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.jsreplaced byeslint.config.js, and airbnb-base consumed throughFlatCompatrather than transcribed - a first attempt at inlining its rules lost about 51 of them, includingno-undefandno-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, sodocs/andtools/are inside the config they were always covered by. 68 findings indocs/, including ten# noqacomments that had outlived their rules - exactly the rotRUF100was selected to catch, which survived because the files it lived in were never scanned. - GitHub Actions to current majors, checked input by input;
codeql-actionv4 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
onlineorinlinesnapshot 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.pointsclouds pick themselves now once a callback is attached andplot.modeis'callback'; nothing happens to a cloud without one.
Full changelog: v3.0.0...v3.0.2