Published September 26, 2026
| Version v0.4.0
Software
Open
dfsp-spirit/scimesh: v0.4.0
Description
Version 0.4.0 -- Batch primitives, tubes, line layers and text labels
- BRK: clip planes are now defined in world space by default. In 0.3.4 and
earlier the
normalandoffsetwere interpreted in eye (camera) space, so the cut travelled with the camera and had to be re-tuned for every viewpoint. For the old behaviour, passPlaneSpace::EYE(C++) orclip_plane(..., space = "eye")(R). Note that the old behaviour was also documented incorrectly: the docs described a world-space plane. See ClipPlane::space and ?clip_plane. - BRK: fog distances (
fog_start/fog_end) are now given in world units (distance from the camera) by default, and are therefore independent of the near/far planes and of the projection type. The legacy interpretation as raw normalized device depth is still available viaFogSpace::NDC(C++) orfog_space = "ndc"(R). Previously the C++ docs claimed world units (which they were not) and the R docs claimed normalized depth in [0, 1] (the values were actually raw depth buffer values in [-1, 1]). - NEW: polyline tubes.
generate_tube()sweeps a circular cross-section along a path of points, andgenerate_multi_tubes()batches many such paths into a single mesh. The cross-section frames are computed by parallel transport (rotation-minimizing frames), so tubes do not twist around their own axis, and mitered joints keep the segments watertight. R:generate_tube()andgenerate_tubes(). Useful for curved edges, streamlines and any shape that is not a straight cylinder. - NEW: spline sampling — smooth curves through ordered 3D points. A new
header-only module (
src/core/scimesh/spline.h) turns a coarse list of waypoints into a dense path, which is what the path-taking features of the library need and what callers previously had to bring themselves (thegenerate_tube()documentation promised "arcs, Bezier samples, streamlines" without offering a way to produce one).catmull_rom_path()interpolates the input points and is the default choice; it is centripetal by default, which is what keeps unevenly spaced points (the norm for measured data) from producing cusps, loops and self-intersections, andalpha = 0recovers the textbook uniform curve.hermite_path()takes caller-supplied tangents instead of deriving them,bspline_path()approximates the points (for smoothing noisy input, at the cost of not passing through them) andbezier_path()samples a control polygon with de Casteljau. Around those,resample_by_arclength()evens out the spacing along a path — the sweep places exactly one cross-section per path point, so uniform arc length spacing is what makes a tube look evenly subdivided — andpath_length(),path_tangents()andpath_curvature()are the queries that drive it.path_curvature()is the one worth knowing about: sweeping a tube of radiusralong a curve whose curvature reacheskappafolds the tube inside out, so1 / max(path_curvature(path))is the largest radius a path can take. Open curves get a reflected phantom neighbour at both ends (so they start and end exactly at the first and last point, along the outer segment), closed curves use their wrapped neighbours, and bothclosed = truecases end on a copy of the first point, so a swept tube closes on itself; the arc-length resampling of a closed path is covered in a whole number of steps so the seam is a regular step instead of a leftover gap. This is pure geometry onstd::vector<Vec3>— no mesh, no renderer, no I/O, no new dependency, and not a domain-specific feature — so it composes with primitives.h, lines.h and your own code alike, including camera paths. R:spline_path(),bezier_path(),resample_path(),path_length()andpath_curvature().examples/cpp/spline_tube/andexamples/R/spline_tube/render the same waypoints as a faceted polyline tube and as a smooth spline tube side by side, plus a closed trefoil knot. - NEW: line layers — screen-space lines as first-class scene primitives. A
LineLayerstores segments, per-segment colors and a width in pixels and creates no geometry, which makes it the cheap way to draw thousands of thin lines (wireframes, graph or connectome edges, trajectories). Line layers are drawn byRenderer::render_scene()in the same pass as the meshes (same camera, same depth buffer, back-to-front blending for translucent lines), they contribute toScene::compute_bounding_box()unless theiraffects_boundsflag is false (see below), and the mesh exporters skip them (scimesh_write_gltf()warns). R:line_layer(), thelinesargument ofscene(), andrender_segments()for free-standing lines. - NEW: line layers are content by default, i.e. they define the bounding box of
their scene exactly like a mesh does, and the camera fitted to the scene
covers them. This is what makes a scene that contains only lines drawable at
all: a tractogram or a connectome without a brain surface used to have no
geometry to derive a camera from. Lines that are decoration rather than
content (a leader line pointing at a label, an axis cross, a scale bar drawn
as segments) can opt out with
LineLayer::affects_bounds = false(R:line_layer(..., affects_bounds = FALSE), andscene_set_line_affects_bounds(scene, index/name, affects_bounds)for a layer that is already part of a scene), so that adding such a layer can never push the camera away from the data. A scene without meshes is framed by its lines even if they all opted out, since there is nothing else to fit. Note that text layers are still ignored by the bounding box, because the extent of a label depends on the font and the output size rather than on world space. - NEW: R function
camera_fit_scene()fits a camera to a whole scene, i.e. to its meshes together with the line layers that affect the bounds. It is the scene counterpart ofcamera_auto()(which takes meshes), and the two share the same framing convention (bounding box center, distance from the extent).camera_auto()now also documents that it does not consider scene contents. - NEW:
Renderer::render_lines_raw()renders line segments directly, andRasterizer::rasterize_line()rasterizes a segment with a screen-space width (flat/unlit by default, like hardware line rendering). - NEW: R functions
generate_multi_spheres()andgenerate_multi_cylinders()expose the batched primitive generators (one mesh for thousands of spheres or cylinders), which previously existed only in C++. - NEW:
capsargument forgenerate_cylinder()andgenerate_multi_cylinders()(R and C++). Open cylinders/tubes use about half the vertices and triangles, which matters for large edge bundles whose ends are hidden inside node spheres. The default is unchanged (caps = TRUE). - PERF: the batched generators (
generate_multi_spheres(),generate_multi_cylinders(),generate_multi_tubes()) reserve the exact final size before merging, instead of growing the arrays incrementally. - FIX: the batched generators no longer read out of bounds when the
radiiorcolorsarray is empty (missing entries are recycled from the first entry; an empty array now means "use the default", i.e. radius 1.0 and white). - FIX:
Rasterizer::rasterize_line()qualified the shadowedwidth/heightmembers in its pixel bounds check (thewidthparameter of the function shadowedRasterizer::width), which made the line invisible. - FIX: the C++ core now compiles with libc++ (clang on macOS).
src/core/primitives.cppusedstd::array(pyramid and tetrahedron faces) without including<array>, andstd::swapwithout<utility>. libstdc++ (GCC, i.e. the Linux and Windows builds) pulls both in transitively, so the bug was invisible there; libc++ does not, so the package failed to install on macOS with "implicit instantiation of undefined template 'std::array<...>'". libc++ only forward-declaresstd::arrayin<__tuple>(fortuple_sizeandtuple_element), which is why the message says "undefined template" instead of "no member named array". This is the installation ERROR reported by the CRAN checks for 0.3.4 on r-release-macos-x86_64, r-oldrel-macos-x86_64 and r-oldrel-macos-arm64. All source files are now verified to compile with libc++ 14 (the toolchain used by those three flavors) and libc++ 18, as well as with libstdc++. - NEW: global anti-aliasing default.
render_options()now takesaa_samples = NULLand resolves thatNULLfrom the global optionscimesh.aa_samples(default 1, i.e. no AA). Settingoptions(scimesh.aa_samples = 2)therefore switches on 2x2 supersampling for every render call that does not passaa_samplesexplicitly (2 for 2x2, 4 for 4x4 SSAA). This is mainly interesting for screen-space lines and points, whose hard edges become smooth boundaries under supersampling; the cost is proportional toaa_samples^2in render time and memory. Passing a number explicitly still overrides the option, and invalid values (including an invalid global option) are reported as errors instead of being ignored. - NEW: text labels — annotations for figures, without any geometry. A
TextLayerstores strings with anchor positions, a font size in pixels, colors and optional halos, and is drawn byRenderer::render_scene()after the meshes and the lines, so labels end up on top of the geometry. Positions are either world space (projected with the scene camera, so a label sticks to the brain region or atom it annotates) or screen space (output pixels, which is what titles, panel tags and captions need). Labels are billboards: they always face the camera and keep their pixel size, and they are hidden by geometry in front of their anchor unlessdepth_test = FALSE, so a world-space label behaves like an annotation of a surface instead of shining through the model.adjplaces the anchor on the text box (as inrgl), pixel offsets nudge a label next to its anchor, multi-line labels are supported, and halos keep text readable on dark or busy geometry. Glyphs are rasterized withstb_truetype(vendored, public domain) from the bundledinst/extdata/Inter-Regular.ttf(SIL OFL 1.1), which can be replaced per label or by setting theSCIMESH_FONTenvironment variable. R:text_layer(), thetextsargument ofscene()andrender_text()for free-standing labels;text_extent()measures labels for layout. Labels can be rotated (rotation, in degrees counter-clockwise about the anchor, in the image plane): 90 turns a label into a vertical axis label, 180 into an upside-down one, and arbitrary angles let a label follow an annotation line. Unrotated labels keep the exact, integer-aligned rasterization path, so existing output is unchanged. - NEW: the low-level font API in C++ —
Font(load a.ttf, metrics, glyph coverage bitmaps, text measurement),draw_text()to draw text into anyImage(including rotated, viaTextDrawStyle::rotation), andmeasure_text()for layout decisions.Font::decode_utf8()means UTF-8 labels ("°C", accented names) work. - NEW:
world_to_screen()(C++ and R) projects world coordinates to the pixels of a rendered image using the same matrices as the renderer, which is what screen-space annotations and callout lines need. - NEW: CLI text options —
--text,--text-at,--text-size,--text-color,--text-halo,--text-halo-width,--text-font,--text-screen,--text-no-depth,--text-adj,--text-offsetand--text-rotation, plus a[text]section inconfig.toml, so figures can be annotated without writing any code. - NEW:
flip_uvs()(C++ and R) mirrors thevtexture coordinate of a mesh (v -> 1 - v), converting UVs that use the bottom-left origin to scimesh's image-space UVs. This is the documented one-liner for UVs that come from OBJ, PLY, rgl, OpenGL or Blender;examples/cpp/spot_cow/uses it. - DOC: the texture-mapping documentation now states the UV convention instead of
leaving it to be discovered. scimesh UVs (
Mesh::uvs, the Ruvargument) are image space, like every other coordinate in the package:v = 0is the top row of the texture image, matchingImage::sample_bilinear(), which the renderer calls with the UVs unchanged. UVs from file formats and other tools (v = 0 at the bottom) have to be converted withflip_uvs(); the vignette and theuv/texturedocumentation say so, and a renderer test pins the orientation so it cannot drift silently. - DOC/FIX: the vignette claimed that
read_obj()extracts UVs and showed a textured-OBJ recipe on that basis. No scimesh reader returns texture coordinates (obj_io::read_obj()andply_io::read_ply()drop them), so the vignette, theread_obj()/read_ply()andrender_mesh()documentation now say what actually happens and how to supply UVs yourself.obj_io.halso promisedvn/vtin its summary while listing them as unsupported. - NEW: R function
clip_plane()creates and validates clip plane descriptors, with an explicitspaceargument ("world" or "eye"), plus examples for both. - NEW:
render_options()validatesclip_planes(normal must be a finite, non-zero length-3 vector;offseta finite number;spacevalid) and the fog settings (finite,fog_end > fog_start). Previously malformed clip planes were silently replaced by defaults in the C++ binding. - NEW:
render_options()exposesnear_planeandfar_plane(defaults 0.1 and 10000, matching the C++ defaults), which define the depth range that NDC fog and mode "ndc" refer to. - NEW: C++
clip_plane_to_view_space()converts a ClipPlane into the view space in which clipping happens (world planes are shifted bydot(normal, eye)). A zero-length normal now neutralizes its plane (clips nothing) instead of discarding the whole scene, and the normal is normalized without rescalingoffset, so non-unit normals no longer move the plane. - NEW: C++
Rasterizer::fog_depth_from_ndc()inverts the depth mapping of both perspective and orthographic projections, so world-space fog works for both. - FIX: fog no longer divides by zero when
fog_start == fog_end. - DOC: document the coordinate space and unit of clip planes and fog in the C++ headers, the R docs and the vignette, with worked examples for world and eye/NDC mode; document that multiple clip planes are ANDed and that point rendering ignores clip planes.
- DEV: add C++ tests (cpp_tests/test_clip_planes.cpp, cpp_tests/test_fog.cpp) and
R tests (tests/testthat/test-clip-planes.R, tests/testthat/test-fog.R) for the
coordinate spaces, camera invariance, plane combination and input validation.
RenderOptions::clip_planesand the fog space had no test coverage at all before this release. - FIX: the
full_CLI_rendererexample could not be linked (and neither could any other consumer that includes libfs.h in more than one translation unit): the vendored libfs header defined its 49 free functions withoutinline, so linkinglibscimesh.a(which contains them, viaobj_io.cpp) together with a TU that also includeslibfs.h/fs_mesh_converter.hfailed with "multiple definition offs::..." errors. The fix belongs to libfs itself (libfs commit ab88cb4, "CHG: inline some functions") and is included in the vendored copy (see the DEP entry below). - DEP: update the vendored libfs (src/third_party/libfs.h) from v0.6.0 to libfs
commit ab88cb4 ("CHG: inline some functions"), i.e. v0.6.1 (upstream commit
7bd0c26, "Volume header fixes": NIfTI sform/qform and vox2ras handling now
match FreeSurfer's
mri_convert) plus the fix that marks all 49 libfs free functionsinline, so that the header can be included from more than one translation unit. ab88cb4 is on the libfsdevelopbranch and not part of a libfs release yet; the next libfs release should therefore be picked up when it is tagged. scimesh only reads meshes through libfs (OBJ viafs::Mesh::from_obj, FreeSurfer surfaces via fs_mesh_converter.h), so the volume/geometry fixes do not change rendering output; the libfs test suite (150339 assertions) passes with the change. - NEW: full_CLI_renderer example: new
[fog] spaceconfig key and--fog-spaceflag ("world" or "ndc"). - FIX:
full_CLI_rendererexample: an unknown--fog-spacevalue now warns and falls back to world units. - FIX:
full_CLI_rendererexample:--outputnow honours a supported image extension (.png,.ppm,.bmp, any case) instead of appending it a second time (--output brain.pngused to writebrain.png.png). Such an extension selects the output format and overrides--format; names without an extension still get the extension of--formatappended, and the "Wrote <file>" message now reports the path that was actually written. - DEV: C++ code coverage can now be measured:
cpp_testsconfigured with-DSCIMESH_COVERAGE=ON(clang) instruments the tests and the library, anddev_tools/coverage_cpp.shbuilds, runs the tests and writes a report (text summary, HTML, lcov trace file) tocoverage/. Test code, the Catch2 amalgamation andsrc/third_party/are excluded from the report. This is a local tool only: no upload, no badge, no coverage gates. First baseline for src/core: 71.8 % lines, 66.1 % branches - withply_io.cppat 0 % andobj_io.cpp/transforms.cppnot even linked into the test binary (no C++ test references them). New C++ tests for mesh file I/O (test_io.cpp), mesh transforms (test_transforms.cpp), renderer features/SSAO (test_render_features.cpp), string formatting (test_to_string.cpp), colormaps (viridis and empty input, intest_colormap.cpp) and the FreeSurfer/PLY brain mesh path (intest_brain_mesh.cpp, whose test data path was wrong so the test silently did nothing) raise this to 91.6 % lines and 79.0 % branches, withply_io.cpp,obj_io.cppandtransforms.cppnow linked and covered (transforms.cppat 100 %). The new R testtests/testthat/test-ssao.Rcovers screen-space ambient occlusion through the R API. - DEV: CI: new
examples.ymlworkflow builds and runs all C++ examples (Linux and macOS) plus the R examples on every push/PR. Previously no CI job compiled the examples at all, which is how the full_CLI_renderer link error could go unnoticed. Newcoverage.ymlworkflow (manual dispatch or push to develop) runs the coverage script and stores the HTML report as a build artifact. - FIX: the mesh transforms
scale_mesh(),rotate_mesh()andtransform_mesh()now update per-vertex normals (C++ and R). They used to move the vertices and leave the normals untouched, which is only correct for translations: afterscale_mesh(mesh, c(1, 1, 4))or any rotation the normals no longer matched the surface, so shading was visibly wrong (users had to passinvert_normals = TRUEor recompute the normals by hand). Normals are now transformed with the inverse transpose of the matrix and renormalized, which is the correct rule for non-uniform scaling, shearing and mirroring; a singular matrix (e.g. a scale of 0) falls back to the plain 3x3 transform. Meshes without normals are unaffected,translate_mesh()still leaves normals alone (as it must), and the C++ header and R docs now state all of this. - FIX:
mesh_from_fs()withdetect_transparency = TRUEcould never mark anything as transparent, so FreeSurfer brain surfaces were rendered with an opaque white medial wall. Vertices whose per-vertex value isNaN(that is FreeSurfer's "no data" marker) were always turned into opaque white, so no vertex ever had an alpha < 1; on top of that, the loop looking for such vertices was guarded byhas_colors(), so it could be skipped entirely. Such vertices now get alpha 0, which makes the renderer blend them (and sethas_transparency), so a medial wall can be seen through. - FIX: screen-space ambient occlusion (
ssao_enabled = TRUE) had no visible effect at all, for three independent reasons: (1)Rasterizer::apply_ssao()treated depth buffer values as if they were in [0, 1] when they are in [-1, 1], so the depth inversion was wrong for both projections. The exact inversion for both projections now lives in one shared helper (view_distance_from_ndc(), whichRasterizer::fog_depth_from_ndc()also uses), so SSAO and world-space fog can no longer disagree about the depth range; (2)ssao_radiusis documented (and now handled) in pixels, but was used as a world/screen-space factor, and is now rounded and clamped to at least one pixel; (3) the hemisphere test that decides whether a sample may occlude mixed normalized device depth with pixel units and had the wrong sign, so it rejected every valid occluder - the sample offset is now built from the world-unit depth difference. Dead code (depth_C/D/E,radius_scale) was removed, and the new tests (C++ and R) assert that SSAO actually changes pixels for a perspective and for an orthographic render. - FIX: translucent geometry lying behind opaque geometry was blended on top of
it, which is what made a very transparent mesh look wrong: the blended pass
ignored the depth buffer entirely
(
if (blend_mode || depth < z_buffer[idx])), so a translucent medial wall painted over the structures inside the brain. Translucent fragments now pass the same depth test as opaque ones; they still do not write depth, so the buffer always holds the nearest opaque surface. - FIX: the translucent pass sorted its triangles the wrong way round. The
centroid depth is a view-space z (the camera looks down -Z, so nearer is
larger), and the sort was descending, i.e. front-to-back: the nearest
translucent surface was drawn first and then covered by the ones behind it.
Sorting is now back-to-front (
a.view_z < b.view_z), which makes the result independent of the order in which the meshes were added to the scene. - NEW: transparency no longer has to be declared.
Mesh::is_transparent()derives it fromdefault_color,colorsandface_colors, and that is what the renderer uses, so per-vertex, per-face and uniform-alpha meshes blend without settingMesh::has_transparency(which is still honored as a manual override, never cleared byMesh::update_transparency()). Readers, the FreeSurfer converters andScene::add()refresh the flag so it is accurate when inspected, and the R layer derives it from the colors it receives. - NEW: PLY vertex colors now keep their alpha. RGBA files (8-bit
alphaas well as floatalpha, including the common case of 8-bit RGB with a float alpha) are read intoColor::aand mark the mesh as translucent; previously alpha was silently dropped (a = 1.0). Alpha without any RGB is kept as a white vertex with that alpha. - NEW: C++
convert_fs_mesh(fs_mesh, morph_data, rgb_colors, nan_alpha)gained thenan_alphaparameter (default 1.0 = unchanged behaviour): pass 0.0 to punch holes at vertices without data (the medial wall) or e.g. 0.5 to draw them half transparent.mesh_from_fs(detect_transparency = TRUE)keeps producing holes. - NEW: R
set_mesh_alpha(mesh, alpha)returns a mesh with a uniform alpha (creating colors fromdefault_color/gray if the mesh has none), the one-liner for a 10 % reference shell. - DOC: document transparency end to end: the alpha column of
colors, the per-vertex interpolation, the back-to-front ordering and depth test, the medial-wall recipe and the remaining limitations (per-triangle ordering, no occlusion by translucent surfaces,render_points()does not blend) in the vignette,?render_mesh,?set_mesh_alpha,Mesh,RasterizerandScene::add(). - DOC:
camera_auto()/camera_fit_scene()/camera_fit_mesh()documenteddirectionas the direction the camera looks along, but the camera is placed atcenter + direction * distance, i.e. it is the side you view the mesh from (c(0,0,1)= front view of a +Z facing mesh). The parameter docs now say so; the behaviour is unchanged. - DEV: new
examples/cpp/transparency/andexamples/R/transparency/(both built and run by the examples CI job) render three scenes: translucent spheres with an opaque sphere inside the shell and one behind it, an alpha ramp from 1.0 to 0.05, and a hemisphere whose medial wall is 50 % transparent with two opaque "voxels" inside it and an 80 % transparent sphere in front. - DEV: new C++ tests (cpp_tests/test_transparency.cpp) and R tests
(tests/testthat/test-transparency.R) cover detection, blending, the depth-test
and ordering fixes, per-vertex alpha interpolation, alpha 0 holes, PLY alpha
round-trips and the FreeSurfer
nan_alphaoption. - FIX: rendering a double-sided mesh was platform dependent and produced visible
artifacts, because two floating point ties were resolved by the rounding of
the platform. (1)
generate_plane()(and other manually built meshes) contain a front and a back face at exactly the same depth, so which of the two wins the depth test is a tie: on some builds the back face won on about a quarter of the pixels and, being shaded with its own (away pointing) normal, came out ambient-lit - an opaque plane was speckled with dark pixels (940 of 4096 at 64x64 on x86-64, with plain-O2and-O3).Rasterizer:: rasterize_triangle()now shades such fragments two-sided, i.e. a visible back side whose normal points away from the viewer is lit with the normal flipped towards the camera, so both faces of the pair look the same and the tie no longer matters. A mesh whose normals disagree with its winding is left alone, soRenderOptions::invert_normalskeeps its effect. (2) The two triangles that make up a quad share a diagonal, and a pixel center lying exactly on it was covered by both of them (blended twice) or by neither (a crack), again depending on the rounding: a translucent plane had a seam of double-attenuated pixels (0.7^4 instead of 0.7^2 of the background). Edge functions are now evaluated in a canonical endpoint order, which makes the value bit-identical in all triangles sharing an edge, and a new edge fill rule (analogous to the "top-left" rule of hardware rasterizers) gives a pixel sitting exactly on an edge to exactly one of them. Coverage and blending no longer depend on the platform; regression tests in cpp_tests/test_rasterizer.cpp and cpp_tests/test_transparency.cpp pin both invariants.
Full Changelog: https://github.com/dfsp-spirit/scimesh/compare/v0.3.4...v0.4.0
Files
dfsp-spirit/scimesh-v0.4.0.zip
Files
(82.8 MB)
| Name | Size | Download all |
|---|---|---|
|
md5:dc8222cdafd39ae754156d37828d61ac
|
82.8 MB | Preview Download |
Additional details
Related works
- Is supplement to
- Software: https://github.com/dfsp-spirit/scimesh/tree/v0.4.0 (URL)
Software
- Repository URL
- https://github.com/dfsp-spirit/scimesh