From 20ed40eb6dc8f781b615a0921c5f0342bd203808 Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Wed, 15 Jul 2026 19:04:06 -0400 Subject: [PATCH 1/6] feat(compose-functions): add findX inverse to vtkPiecewiseFunction findX(y) returns an x such that getValue(x) === y. It locates the first segment whose endpoint values bracket y and bisects it through forward evaluation, so midpoint and sharpness are honored without duplicating their math; within a segment the curve is always monotonic between its endpoint values, so bisection converges. Flat (plateau) segments need no special casing: a plateau value matches the endpoint check and returns the plateau's left node (an equally valid inverse). Discontinuous segments (sharpness ~1) return the jump location. For y outside the output range with clamping on, the endpoint holding the nearest extremum is returned; null is returned when no x qualifies (extremum only attained at an interior node, clamping off, or empty function). Co-Authored-By: Claude Fable 5 --- .../DataModel/PiecewiseFunction/index.d.ts | 13 ++ .../DataModel/PiecewiseFunction/index.js | 69 +++++++ .../test/testPiecewiseFunction.js | 187 ++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 Sources/Common/DataModel/PiecewiseFunction/test/testPiecewiseFunction.js diff --git a/Sources/Common/DataModel/PiecewiseFunction/index.d.ts b/Sources/Common/DataModel/PiecewiseFunction/index.d.ts index 5b52ff93c9d..4676cc39124 100644 --- a/Sources/Common/DataModel/PiecewiseFunction/index.d.ts +++ b/Sources/Common/DataModel/PiecewiseFunction/index.d.ts @@ -84,6 +84,19 @@ export interface vtkPiecewiseFunction extends vtkObject { */ getFirstNonZeroValue(): number; + /** + * Inverse of getValue(): given a value y, returns an x such that + * getValue(x) === y, bisecting the first segment whose endpoint values + * bracket y using forward evaluation, so midpoint and sharpness are + * honored. For a discontinuous segment (sharpness ~1) the jump location + * is returned. When y is outside the function's output range: with + * clamping on, returns the endpoint x whose node holds the nearest + * extremum; returns null if that extremum is only attained at an + * interior node (no x clamps to y) or if clamping is off. + * @param {Number} y + */ + findX(y: number): number | null; + /** * For the node specified by index, set/get the location (X), value (Y), * midpoint, and sharpness values at the node. diff --git a/Sources/Common/DataModel/PiecewiseFunction/index.js b/Sources/Common/DataModel/PiecewiseFunction/index.js index 56d34d1d344..4cc4cb4d96f 100644 --- a/Sources/Common/DataModel/PiecewiseFunction/index.js +++ b/Sources/Common/DataModel/PiecewiseFunction/index.js @@ -338,6 +338,75 @@ function vtkPiecewiseFunction(publicAPI, model) { return table[0]; }; + // Inverse of getValue(). See index.d.ts for the full contract. + publicAPI.findX = (y) => { + const { nodes } = model; + for (let i = 0; i < nodes.length - 1; i++) { + const { x: x0, y: y0 } = nodes[i]; + const { x: x1, y: y1 } = nodes[i + 1]; + if (y === y0) { + return x0; + } + if (y === y1) { + return x1; + } + const minY = Math.min(y0, y1); + const maxY = Math.max(y0, y1); + if (y > minY && y < maxY) { + // Within a segment, forward evaluation is monotonic from y0 to + // y1 regardless of midpoint and sharpness, so bisect until + // floating-point convergence. + const increasing = y1 > y0; + let lo = x0; + let hi = x1; + let mid = 0.5 * (lo + hi); + while (lo < mid && mid < hi) { + const value = publicAPI.getValue(mid); + if (value === y) { + return mid; + } + if (value < y === increasing) { + lo = mid; + } else { + hi = mid; + } + mid = 0.5 * (lo + hi); + } + return mid; + } + } + if (model.clamping && nodes.length > 0) { + let minY = Infinity; + let maxY = -Infinity; + nodes.forEach((node) => { + minY = Math.min(minY, node.y); + maxY = Math.max(maxY, node.y); + }); + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + if (y >= maxY) { + if (first.y === maxY) { + return first.x; + } + if (last.y === maxY) { + return last.x; + } + return null; + } + if (y <= minY) { + if (first.y === minY) { + return first.x; + } + if (last.y === minY) { + return last.x; + } + return null; + } + } + + return null; + }; + // Remove all points outside the range, and make sure a point // exists at each end of the range. Used as a convenience method // for transfer function editors diff --git a/Sources/Common/DataModel/PiecewiseFunction/test/testPiecewiseFunction.js b/Sources/Common/DataModel/PiecewiseFunction/test/testPiecewiseFunction.js new file mode 100644 index 00000000000..48963871f52 --- /dev/null +++ b/Sources/Common/DataModel/PiecewiseFunction/test/testPiecewiseFunction.js @@ -0,0 +1,187 @@ +import { it, expect } from 'vitest'; +import vtkPiecewiseFunction from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction'; + +it('Test findX on a simple linear function', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 0); + fn.addPoint(10, 100); + + expect(fn.findX(50)).toBeCloseTo(5); + expect(fn.findX(0)).toBeCloseTo(0); + expect(fn.findX(100)).toBeCloseTo(10); +}); + +it('Test findX with multiple segments', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 0); + fn.addPoint(5, 10); + fn.addPoint(10, 10); + + // y === 10 is matched by the end of the first (rising) segment before the + // flat segment is ever reached. + expect(fn.findX(5)).toBeCloseTo(2.5); + expect(fn.findX(10)).toBeCloseTo(5); +}); + +it('Test findX on a flat (zero-slope) segment', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 5); + fn.addPoint(5, 5); // flat segment from x=0 to x=5 + fn.addPoint(10, 15); + + // Every x on the plateau evaluates to 5, so the inverse is non-unique; + // the first matching node in scan order (the plateau's left edge) is + // returned. + expect(fn.findX(5)).toBe(0); + expect(fn.getValue(fn.findX(5))).toBe(5); + // Values above the plateau invert through the rising segment. + expect(fn.findX(10)).toBeCloseTo(7.5); +}); + +it('Test findX on a decreasing function', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 100); + fn.addPoint(10, 0); + + expect(fn.findX(50)).toBeCloseTo(5); +}); + +it('Test findX on a decreasing shaped segment (round trip)', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPointLong(0, 100, 0.25, 0.5); // non-default midpoint and sharpness + fn.addPoint(10, 0); + + [90, 50, 10].forEach((y) => { + expect(fn.getValue(fn.findX(y))).toBeCloseTo(y); + }); +}); + +it('Test findX on a non-monotonic function', () => { + const fn = vtkPiecewiseFunction.newInstance(); + // Rise, fall, rise: 40 -> 100 -> 0 -> 60. The falling segment is shaped + // by a non-default midpoint and sharpness so its inverse must go + // through bisection, not endpoint interpolation. + fn.addPoint(0, 40); + fn.addPointLong(4, 100, 0.3, 0.6); + fn.addPoint(6, 0); + fn.addPoint(10, 60); + + // y = 70 has a pre-image in both rising segments and the falling one; + // the leftmost bracketing segment (the linear rise on [0, 4]) wins. + expect(fn.findX(70)).toBeCloseTo(2); + + // y = 20 is below the first segment's range, so the falling shaped + // segment on [4, 6] is the leftmost match — not the final rise, which + // also attains 20 (at x ≈ 7.33). + const x20 = fn.findX(20); + expect(x20).toBeGreaterThan(4); + expect(x20).toBeLessThan(6); + expect(fn.getValue(x20)).toBeCloseTo(20); + + // Node values are returned exactly, including the interior extremes. + expect(fn.findX(100)).toBe(4); + expect(fn.findX(0)).toBe(6); + + // Round trips hold across the whole output range. + [5, 20, 45, 70, 95].forEach((y) => { + expect(fn.getValue(fn.findX(y))).toBeCloseTo(y); + }); +}); + +it('Test findX honors a non-default midpoint', () => { + const fn = vtkPiecewiseFunction.newInstance(); + // The segment shape is controlled by the leading node's midpoint and + // sharpness: the curve reaches (y0 + y1) / 2 at 25% of the segment. + fn.addPointLong(0, 0, 0.25, 0); + fn.addPoint(10, 100); + + expect(fn.findX(50)).toBeCloseTo(2.5); + expect(fn.getValue(fn.findX(50))).toBeCloseTo(50); + expect(fn.getValue(fn.findX(80))).toBeCloseTo(80); +}); + +it('Test findX honors sharpness (round trip through forward evaluation)', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPointLong(0, 0, 0.5, 0.5); + fn.addPoint(10, 100); + + [10, 30, 50, 70, 90].forEach((y) => { + expect(fn.getValue(fn.findX(y))).toBeCloseTo(y); + }); +}); + +it('Test findX returns the jump location for a piecewise-constant segment', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPointLong(0, 0, 0.5, 1.0); // sharpness 1: step at the midpoint + fn.addPoint(10, 100); + + // No x evaluates to 50; the discontinuity at x=5 is returned. + expect(fn.findX(50)).toBeCloseTo(5); + // Exact endpoint values are still exact. + expect(fn.findX(0)).toBe(0); + expect(fn.findX(100)).toBe(10); +}); + +it('Test findX out-of-range with clamping on', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 0); + fn.addPoint(10, 100); + fn.setClamping(true); + + expect(fn.findX(-10)).toBe(0); + expect(fn.findX(200)).toBe(10); +}); + +it('Test findX out-of-range on a decreasing function with clamping on', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 100); + fn.addPoint(10, 0); + fn.setClamping(true); + + // The maximum output (100) is attained at x=0, the minimum (0) at x=10. + expect(fn.findX(200)).toBe(0); + expect(fn.findX(-10)).toBe(10); +}); + +it('Test findX out-of-range on a non-monotonic function with clamping on', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 0); + fn.addPoint(5, 100); // interior maximum + fn.addPoint(10, 20); + fn.setClamping(true); + + // Above the output range: the maximum is only attained at an interior + // node, so no x clamps to y (forward evaluation clamps to endpoint + // values only). + expect(fn.findX(200)).toBeNull(); + // Below the output range: the minimum is attained at an endpoint. + expect(fn.findX(-10)).toBe(0); +}); + +it('Test findX out-of-range when an endpoint shares the interior extremum', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 100); + fn.addPoint(5, 100); // interior node ties the endpoint maximum + fn.addPoint(10, 0); + fn.setClamping(true); + + // The maximum is attained at an endpoint, so it remains a valid clamp + // target even though an interior node shares the same value. + expect(fn.findX(200)).toBe(0); + expect(fn.findX(-10)).toBe(10); +}); + +it('Test findX out-of-range with clamping off', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 0); + fn.addPoint(10, 100); + fn.setClamping(false); + + expect(fn.findX(-10)).toBeNull(); + expect(fn.findX(200)).toBeNull(); +}); + +it('Test findX on an empty function', () => { + const fn = vtkPiecewiseFunction.newInstance(); + expect(fn.findX(0)).toBeNull(); +}); From 8efb9d77a120deaa29d4d3e3dd4fc9cc100afaf7 Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Thu, 16 Jul 2026 15:37:10 -0400 Subject: [PATCH 2/6] feat(color-transfer-function): add getDataPointer for VTK parity Add vtkColorTransferFunction.getDataPointer() returning the function's nodes, mirroring the VTK/C++ API. The returned array is a live read-only reference to the internal nodes, documented as such: direct writes bypass sortAndUpdateRange() and modified(), so changes would not propagate through the pipeline. Co-Authored-By: Claude Fable 5 --- .../Rendering/Core/ColorTransferFunction/index.d.ts | 11 +++++++++++ Sources/Rendering/Core/ColorTransferFunction/index.js | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/Sources/Rendering/Core/ColorTransferFunction/index.d.ts b/Sources/Rendering/Core/ColorTransferFunction/index.d.ts index f099e87c727..a00eb377125 100755 --- a/Sources/Rendering/Core/ColorTransferFunction/index.d.ts +++ b/Sources/Rendering/Core/ColorTransferFunction/index.d.ts @@ -106,6 +106,17 @@ export interface vtkColorTransferFunction extends vtkScalarsToColors { */ getSize(): number; + /** + * Returns the nodes stored in the function, as an array of + * `{ x, r, g, b, midpoint, sharpness }` objects. + * This is intended as a read-only reference to internally stored nodes + * that should not be modified outside the vtkColorTransferFunction + * object: direct writes bypass sortAndUpdateRange() and modified(), so + * the nodes can end up unsorted, the cached range stale, and the change + * will not propagate through the pipeline. + */ + getDataPointer(): any[]; + /** * Set nodes directly * @param nodes diff --git a/Sources/Rendering/Core/ColorTransferFunction/index.js b/Sources/Rendering/Core/ColorTransferFunction/index.js index 99da0c1a9eb..788b40f4a9c 100644 --- a/Sources/Rendering/Core/ColorTransferFunction/index.js +++ b/Sources/Rendering/Core/ColorTransferFunction/index.js @@ -1273,6 +1273,11 @@ function vtkColorTransferFunction(publicAPI, model) { return modifiedInvoked || callModified; }; + + //---------------------------------------------------------------------------- + publicAPI.getDataPointer = () => { + return model.nodes; + }; } // ---------------------------------------------------------------------------- From 7e95176d6b9dcc5b351b5bfb8ff6d363b56f88d4 Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Wed, 15 Jul 2026 19:04:21 -0400 Subject: [PATCH 3/6] feat(compose-functions): add compose helper for piecewise function chains compose(fnList, colorFn, outputFn, errorThreshold?, maxSubdivisionDepth?) composes an ordered chain of monotonic piecewise value-transform functions with a final-stage color transfer function into a single output color transfer function. Breakpoints from every stage (and from the color function) are pulled back through the upstream inverses into the source domain, then forward-propagated to sample the composed color at each one. Transform functions must be monotonic so breakpoints can be pulled back unambiguously: any input whose getType() is 'Varied' aborts the composition. Without an errorThreshold the output interpolates linearly between breakpoints, so transform functions must additionally be piecewise linear (midpoint 0.5, sharpness 0; the last node is exempt since its parameters shape a transition to a node that does not exist). With an errorThreshold, shaped segments in any stage are captured instead by recursively subdividing each output segment at its midpoint until linear interpolation matches the true composed color to within the threshold on every channel. The subdivision is depth-first, so the threshold alone cannot bound the node count; maxSubdivisionDepth (default 24) guarantees termination at discontinuities and caps the worst case at 2^depth - 1 extra nodes per segment. The color transfer function is never restricted: its breakpoint colors are always exact, and its shaped segments are approximated when a threshold is given or linearized otherwise. Returns the maximum measured midpoint deviation between the composed output and the true composed chain (0 means exact; a value above errorThreshold means the depth cap stopped refinement), or null when an input is rejected, leaving outputFn untouched. 0 is falsy: callers must test the result against null, never its truthiness. Co-Authored-By: Claude Fable 5 --- .../DataModel/PiecewiseFunction/helpers.d.ts | 38 ++ .../DataModel/PiecewiseFunction/helpers.js | 158 ++++++++ .../PiecewiseFunction/test/testHelpers.js | 350 ++++++++++++++++++ 3 files changed, 546 insertions(+) create mode 100644 Sources/Common/DataModel/PiecewiseFunction/helpers.d.ts create mode 100644 Sources/Common/DataModel/PiecewiseFunction/helpers.js create mode 100644 Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js diff --git a/Sources/Common/DataModel/PiecewiseFunction/helpers.d.ts b/Sources/Common/DataModel/PiecewiseFunction/helpers.d.ts new file mode 100644 index 00000000000..618bc09055c --- /dev/null +++ b/Sources/Common/DataModel/PiecewiseFunction/helpers.d.ts @@ -0,0 +1,38 @@ +import { vtkPiecewiseFunction } from '../PiecewiseFunction'; +import { vtkColorTransferFunction } from '../../../Rendering/Core/ColorTransferFunction'; + +/** + * Compose a chain of piecewise (value) transform functions and a color + * transfer function into a single output color transfer function. + * + * Collects all x-positions across all transform functions and chains their + * outputs through to the color function, producing equivalent break points + * in the composed result. h(g(f(x))): g's x-values live in f's output domain + * and must be pulled back through f-inverse; h's x-values need g-inverse then + * f-inverse, and so on. + * + * Every transform function in fnList must be monotonic so that its + * breakpoints can be pulled back through the chain unambiguously. Without + * an errorThreshold the composed result interpolates linearly between the + * collected breakpoints, so every transform function must additionally be + * piecewise linear (midpoint 0.5, sharpness 0 on every segment); with an + * errorThreshold, shaped (non-linear) segments in any stage are captured + * through subdivision instead. The final-stage color transfer function is + * never restricted: its breakpoint colors are always reproduced exactly, + * and its shaped segments are approximated to within errorThreshold when + * one is provided (they are linearized between breakpoints otherwise). + * + * @param {vtkPiecewiseFunction[]} fnList ordered list of value transform functions, e.g. [modalityFn, voiFn, userFn] + * @param {vtkColorTransferFunction} colorFn final-stage color transfer function + * @param {vtkColorTransferFunction} outputFn function to populate with the composed result + * @param {Number} [errorThreshold] per-color-channel tolerance; when given, each output segment is recursively subdivided at its midpoint until linear interpolation matches the true composed color to within the threshold on every channel + * @param {Number} [maxSubdivisionDepth] recursion depth cap per segment (default 24). The subdivision is depth-first, so the threshold alone cannot bound the node count; tune both to control the maximum nodes produced (worst case 2^depth - 1 extra nodes per segment) + * @returns {Number|null} the maximum measured midpoint deviation between the composed output and the true composed chain (0 means exact; a value above errorThreshold means the depth cap stopped refinement somewhere), or null if any transform function is not monotonic, or is not piecewise linear when no errorThreshold is given (outputFn is left untouched). 0 is falsy: test the result against null for success, never its truthiness + */ +export function compose( + fnList: vtkPiecewiseFunction[], + colorFn: vtkColorTransferFunction, + outputFn: vtkColorTransferFunction, + errorThreshold?: number, + maxSubdivisionDepth?: number +): number | null; diff --git a/Sources/Common/DataModel/PiecewiseFunction/helpers.js b/Sources/Common/DataModel/PiecewiseFunction/helpers.js new file mode 100644 index 00000000000..ecdcf9703c0 --- /dev/null +++ b/Sources/Common/DataModel/PiecewiseFunction/helpers.js @@ -0,0 +1,158 @@ +import macro from 'vtk.js/Sources/macros'; + +const { vtkWarningMacro } = macro; + +// Default cap on recursive subdivision in compose(): 2^-24 of a segment +// is beyond float32 texture precision. +const DEFAULT_MAX_SUBDIVISION_DEPTH = 24; + +function getColorFunctionXValues(cfun) { + const size = cfun.getSize(); + const xValues = new Array(size); + cfun.getDataPointer().forEach((n, index) => { + xValues[index] = n.x; + }); + return xValues; +} + +function isPiecewiseLinear(fn) { + const v = [0, 0, 0, 0]; // [x, y, midpoint, sharpness] + // The last node's midpoint and sharpness shape the transition to a + // following node that does not exist, so it is exempt. + for (let i = 0; i < fn.getSize() - 1; i++) { + fn.getNodeValue(i, v); + if (v[2] !== 0.5 || v[3] !== 0) { + return false; + } + } + return true; +} + +// Compose a chain of piecewise value-transform functions and a final-stage +// color transfer function into a single output color transfer function. +// See helpers.d.ts for the full contract. +export function compose( + fnList, + colorFn, + outputFn, + errorThreshold = null, + maxSubdivisionDepth = DEFAULT_MAX_SUBDIVISION_DEPTH +) { + const variedIdx = fnList.findIndex((fn) => fn.getType() === 'Varied'); + if (variedIdx !== -1) { + vtkWarningMacro( + `compose: transform function at index ${variedIdx} is not ` + + 'monotonic; aborting composition.' + ); + return null; + } + + if (errorThreshold == null) { + const nonlinearIdx = fnList.findIndex((fn) => !isPiecewiseLinear(fn)); + if (nonlinearIdx !== -1) { + vtkWarningMacro( + `compose: transform function at index ${nonlinearIdx} is not ` + + 'piecewise linear (midpoint 0.5, sharpness 0) and no ' + + 'errorThreshold is given to capture its shape through ' + + 'subdivision; aborting composition.' + ); + return null; + } + } + + const xSet = new Set(); + + // Pull a breakpoint back through fnList[idx - 1] .. fnList[0] into the + // original data domain. Returns null when the breakpoint cannot be + // inverted (findX returns null for values unattainable by the stage). + function pullBack(startIdx, value) { + let x = value; + for (let j = startIdx; j >= 0; j--) { + x = fnList[j].findX(x); + if (x == null) { + return null; + } + } + return x; + } + + // Each function's breakpoint x-values live in that function's input domain. + fnList.forEach((fn, idx) => { + const data = fn.getDataPointer(); + if (!data) return; + for (let i = 0; i < data.length; i += 2) { + const x = pullBack(idx - 1, data[i]); + if (x != null) xSet.add(x); + } + }); + + // Also reverse compute from x-values of the final-stage color transfer function, + // and add those to our xSet so that we don't miss any break points defined + // within the color transfer function. + const colorXs = getColorFunctionXValues(colorFn); + colorXs.forEach((x) => { + const t = pullBack(fnList.length - 1, x); + if (t != null) xSet.add(t); + }); + + // Now use the gathered x values to propogate through the entire set of + // functions to determine the final color values for each, and add them + // as nodes to our new color transfer function. + const xs = Array.from(xSet).sort((a, b) => a - b); + + function composedColor(x) { + const finalScalar = fnList.reduce((val, fn) => fn.getValue(val), x); + const rgb = [0, 0, 0]; + colorFn.getColor(finalScalar, rgb); + return rgb; + } + + // Recursively split [x0, x1] at its midpoint until linear interpolation + // of the endpoint colors matches the true composed color on every + // channel to within errorThreshold, and return the maximum midpoint + // deviation left in the emitted segments. The depth cap guarantees + // termination around discontinuities (colorFn sharpness ~1), where the + // midpoint test can never pass; a residual above errorThreshold means + // the cap stopped refinement. + function subdivide(x0, c0, x1, c1, depth) { + const xm = 0.5 * (x0 + x1); + if (xm <= x0 || xm >= x1) { + // Float precision exhausted; nothing left to measure or refine. + return 0; + } + const cm = composedColor(xm); + const deviation = cm.reduce( + (max, c, i) => Math.max(max, Math.abs(c - 0.5 * (c0[i] + c1[i]))), + 0 + ); + if (depth <= 0 || (errorThreshold != null && deviation <= errorThreshold)) { + return deviation; + } + // The midpoint becomes a node (exact there), so the residual is + // whatever the two halves leave behind. + const leftError = subdivide(x0, c0, xm, cm, depth - 1); + outputFn.addRGBPoint(xm, cm[0], cm[1], cm[2]); + const rightError = subdivide(xm, cm, x1, c1, depth - 1); + return Math.max(leftError, rightError); + } + + const colors = xs.map(composedColor); + // Without a threshold, depth 0 measures each segment's midpoint + // deviation without subdividing, so the returned error is meaningful + // in both modes. + const depth = errorThreshold != null ? maxSubdivisionDepth : 0; + let maxError = 0; + outputFn.removeAllPoints(); + xs.forEach((x, i) => { + const rgb = colors[i]; + outputFn.addRGBPoint(x, rgb[0], rgb[1], rgb[2]); + if (i + 1 < xs.length) { + maxError = Math.max( + maxError, + subdivide(x, rgb, xs[i + 1], colors[i + 1], depth) + ); + } + }); + + return maxError; +} diff --git a/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js b/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js new file mode 100644 index 00000000000..d35af03f827 --- /dev/null +++ b/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js @@ -0,0 +1,350 @@ +import { it, expect } from 'vitest'; +import macro from 'vtk.js/Sources/macros'; +import vtkPiecewiseFunction from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction'; +import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction'; +import { compose } from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction/helpers'; + +function getNodeXs(fn) { + const xs = []; + const v = [0, 0, 0, 0, 0, 0]; // [x, r, g, b, midpoint, sharpness] + for (let i = 0; i < fn.getSize(); i++) { + fn.getNodeValue(i, v); + xs.push(v[0]); + } + return xs; +} + +it('Test compose with an identity transform passes colorFn through unchanged', () => { + const identityFn = vtkPiecewiseFunction.newInstance(); + identityFn.addPoint(0, 0); + identityFn.addPoint(100, 100); + + const colorFn = vtkColorTransferFunction.newInstance(); + colorFn.addRGBPoint(0, 1, 0, 0); + colorFn.addRGBPoint(100, 0, 0, 1); + + const outputFn = vtkColorTransferFunction.newInstance(); + // All-linear composition is exact: the measured error is (falsy!) 0, + // so success must be tested against null, never truthiness. + const maxError = compose([identityFn], colorFn, outputFn); + expect(maxError).not.toBeNull(); + expect(maxError).toBeLessThan(1e-10); + + expect(getNodeXs(outputFn)).toEqual([0, 100]); + expect(outputFn.getRange()).toEqual([0, 100]); + + [0, 100].forEach((x) => { + const expected = []; + const actual = []; + colorFn.getColor(x, expected); + outputFn.getColor(x, actual); + expect(actual).toEqual(expected); + }); +}); + +it('Test compose maps a color function breakpoint back to its source domain', () => { + // y = x + 10 + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 10); + fn.addPoint(100, 110); + + const colorFn = vtkColorTransferFunction.newInstance(); + colorFn.addRGBPoint(10, 1, 0, 0); + colorFn.addRGBPoint(60, 0, 1, 0); // interior breakpoint -> maps back to x=50 + colorFn.addRGBPoint(110, 0, 0, 1); + + const outputFn = vtkColorTransferFunction.newInstance(); + compose([fn], colorFn, outputFn); + + const xs = getNodeXs(outputFn); + expect(xs.length).toBe(3); + expect(xs[0]).toBeCloseTo(0); + expect(xs[1]).toBeCloseTo(50); + expect(xs[2]).toBeCloseTo(100); + + xs.forEach((x) => { + const finalScalar = fn.getValue(x); + const expected = []; + const actual = []; + colorFn.getColor(finalScalar, expected); + outputFn.getColor(x, actual); + expect(actual).toEqual(expected); + }); +}); + +it('Test compose chains multiple transform functions and propagates breakpoints across stages', () => { + // fn1: y = x * 0.5 over [0,100] -> output range [0,50] + const fn1 = vtkPiecewiseFunction.newInstance(); + fn1.addPoint(0, 0); + fn1.addPoint(100, 50); + + // fn2 takes fn1's output [0,50] and has an interior breakpoint at (25, 80) + const fn2 = vtkPiecewiseFunction.newInstance(); + fn2.addPoint(0, 0); + fn2.addPoint(25, 80); + fn2.addPoint(50, 100); + + const colorFn = vtkColorTransferFunction.newInstance(); + colorFn.addRGBPoint(0, 1, 0, 0); + colorFn.addRGBPoint(100, 0, 0, 1); + + const outputFn = vtkColorTransferFunction.newInstance(); + compose([fn1, fn2], colorFn, outputFn); + + const xs = getNodeXs(outputFn); + expect(xs.length).toBe(3); + expect(xs[0]).toBeCloseTo(0); + // fn2's interior breakpoint (x=25 in fn1's output domain) inverted through + // fn1 lands at x=50 in the original data domain. + expect(xs[1]).toBeCloseTo(50); + expect(xs[2]).toBeCloseTo(100); + + xs.forEach((x) => { + const finalScalar = fn2.getValue(fn1.getValue(x)); + const expected = []; + const actual = []; + colorFn.getColor(finalScalar, expected); + outputFn.getColor(x, actual); + expect(actual).toEqual(expected); + }); +}); + +it('Test compose fails early when a transform function is not monotonic', () => { + const fn1 = vtkPiecewiseFunction.newInstance(); + fn1.addPoint(0, 0); + fn1.addPoint(100, 100); + + // Non-monotonic (Varied) transform: breakpoints cannot be pulled back + // through it unambiguously. + const fn2 = vtkPiecewiseFunction.newInstance(); + fn2.addPoint(0, 0); + fn2.addPoint(50, 100); + fn2.addPoint(100, 20); + + const colorFn = vtkColorTransferFunction.newInstance(); + colorFn.addRGBPoint(0, 1, 0, 0); + colorFn.addRGBPoint(100, 0, 0, 1); + + const outputFn = vtkColorTransferFunction.newInstance(); + outputFn.addRGBPoint(42, 1, 1, 1); // pre-existing content must survive + + const warnings = []; + macro.setLoggerFunction('warn', (...args) => warnings.push(args.join(' '))); + try { + expect(compose([fn1, fn2], colorFn, outputFn)).toBeNull(); + } finally { + macro.setLoggerFunction('warn', console.warn); + } + + // Failed composition leaves outputFn untouched and reports the culprit. + expect(getNodeXs(outputFn)).toEqual([42]); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('index 1'); + expect(warnings[0]).toContain('not monotonic'); +}); + +it('Test compose rejects a shaped transform without a threshold but subdivides with one', () => { + // Piecewise linear despite the shaped LAST node: its midpoint and + // sharpness would only affect a transition to a following node that + // does not exist, so it must not trip the check. + const fn1 = vtkPiecewiseFunction.newInstance(); + fn1.addPoint(0, 0); + fn1.addPointLong(100, 100, 0.3, 0.6); + + // Shaped interior segment: the composed result interpolates linearly + // between breakpoints and cannot reproduce it. + const fn2 = vtkPiecewiseFunction.newInstance(); + fn2.addPointLong(0, 0, 0.3, 0.6); + fn2.addPoint(100, 100); + + const colorFn = vtkColorTransferFunction.newInstance(); + colorFn.addRGBPoint(0, 1, 0, 0); + colorFn.addRGBPoint(100, 0, 0, 1); + + const outputFn = vtkColorTransferFunction.newInstance(); + outputFn.addRGBPoint(42, 1, 1, 1); // pre-existing content must survive + + const warnings = []; + macro.setLoggerFunction('warn', (...args) => warnings.push(args.join(' '))); + try { + expect(compose([fn1, fn2], colorFn, outputFn)).toBeNull(); + // Failed composition leaves outputFn untouched and reports the culprit. + expect(getNodeXs(outputFn)).toEqual([42]); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('index 1'); + expect(warnings[0]).toContain('not piecewise linear'); + + // With the offending function removed, the shaped last node alone + // composes fine. + expect(compose([fn1], colorFn, outputFn)).not.toBeNull(); + + // With an errorThreshold, the shaped transform is admitted and its + // non-linearity is captured through subdivision: the returned error + // meets the threshold. + const threshold = 0.01; + const subdividedFn = vtkColorTransferFunction.newInstance(); + const maxError = compose([fn1, fn2], colorFn, subdividedFn, threshold); + expect(maxError).not.toBeNull(); + expect(maxError).toBeLessThanOrEqual(threshold); + expect(subdividedFn.getSize()).toBeGreaterThan(2); + const xs = getNodeXs(subdividedFn); + for (let i = 0; i + 1 < xs.length; i++) { + const xm = 0.5 * (xs[i] + xs[i + 1]); + const expected = []; + const actual = []; + colorFn.getColor(fn2.getValue(fn1.getValue(xm)), expected); + subdividedFn.getColor(xm, actual); + expected.forEach((channel, c) => { + expect(Math.abs(actual[c] - channel)).toBeLessThanOrEqual( + threshold + 1e-6 + ); + }); + } + } finally { + macro.setLoggerFunction('warn', console.warn); + } +}); + +it('Test compose subdivides output segments to meet an error threshold', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 0); + fn.addPoint(100, 100); + + // Strongly shaped (non-linear) color segment. + const colorFn = vtkColorTransferFunction.newInstance(); + colorFn.addRGBPointLong(0, 1, 0, 0, 0.25, 0.9); + colorFn.addRGBPoint(100, 0, 0, 1); + + // Without a threshold the shaped segment is linearized between the two + // breakpoints, and the returned error reports the (large) deviation. + const threshold = 0.01; + const flatFn = vtkColorTransferFunction.newInstance(); + const flatError = compose([fn], colorFn, flatFn); + expect(flatFn.getSize()).toBe(2); + expect(flatError).toBeGreaterThan(threshold); + + // With a threshold, interior nodes are added until the error meets it. + const outputFn = vtkColorTransferFunction.newInstance(); + const maxError = compose([fn], colorFn, outputFn, threshold); + expect(maxError).not.toBeNull(); + expect(maxError).toBeLessThanOrEqual(threshold); + expect(outputFn.getSize()).toBeGreaterThan(2); + + // Between every adjacent pair of output nodes, the composed (linear) + // interpolation at the midpoint matches the true color on every channel + // to within the threshold. + const xs = getNodeXs(outputFn); + for (let i = 0; i + 1 < xs.length; i++) { + const xm = 0.5 * (xs[i] + xs[i + 1]); + const expected = []; + const actual = []; + colorFn.getColor(fn.getValue(xm), expected); + outputFn.getColor(xm, actual); + expected.forEach((channel, c) => { + expect(Math.abs(actual[c] - channel)).toBeLessThanOrEqual( + threshold + 1e-6 + ); + }); + } +}); + +it('Test compose returns the maximum measured midpoint error across segments', () => { + // Two linear transform segments of different slopes. + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 0); + fn.addPoint(50, 25); + fn.addPoint(100, 100); + + // Shaped color segment over [0, 25], linear over [25, 100]: only the + // first composed segment deviates from linear interpolation. + const colorFn = vtkColorTransferFunction.newInstance(); + colorFn.addRGBPointLong(0, 1, 0, 0, 0.25, 0.9); + colorFn.addRGBPoint(25, 0, 1, 0); + colorFn.addRGBPoint(100, 0, 0, 1); + + const outputFn = vtkColorTransferFunction.newInstance(); + const maxError = compose([fn], colorFn, outputFn); + expect(maxError).not.toBeNull(); + + // Breakpoints: fn's nodes plus colorFn's node at 25 pulled back to 50. + expect(getNodeXs(outputFn)).toEqual([0, 50, 100]); + + // Recompute each segment's midpoint deviation independently through + // forward evaluation. + const deviation = (x0, x1) => { + const xm = 0.5 * (x0 + x1); + const cm = []; + const c0 = []; + const c1 = []; + colorFn.getColor(fn.getValue(xm), cm); + colorFn.getColor(fn.getValue(x0), c0); + colorFn.getColor(fn.getValue(x1), c1); + return cm.reduce( + (max, c, i) => Math.max(max, Math.abs(c - 0.5 * (c0[i] + c1[i]))), + 0 + ); + }; + const first = deviation(0, 50); + const second = deviation(50, 100); + + // The shaped segment comes first and dominates; an implementation + // returning the last measured deviation instead of the maximum would + // report ~0. + expect(first).toBeGreaterThan(0.1); + expect(second).toBeLessThan(1e-10); + expect(maxError).toBeCloseTo(first, 12); +}); + +it('Test compose subdivision terminates on a discontinuous color segment', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 0); + fn.addPoint(100, 100); + + // sharpness 1: a step at the segment midpoint. The midpoint criterion + // can never be met across the jump, so only the depth cap stops the + // recursion there. + const colorFn = vtkColorTransferFunction.newInstance(); + colorFn.addRGBPointLong(0, 1, 0, 0, 0.5, 1.0); + colorFn.addRGBPoint(100, 0, 0, 1); + + const outputFn = vtkColorTransferFunction.newInstance(); + const maxError = compose([fn], colorFn, outputFn, 0.01); + // The returned error exceeding the threshold tells the caller the + // depth cap stopped refinement (nothing can converge across a jump). + expect(maxError).toBeGreaterThan(0.01); + + const size = outputFn.getSize(); + expect(size).toBeGreaterThan(2); + // Node growth stays bounded: roughly two nodes per recursion level + // funneling toward the jump, not an explosion. + expect(size).toBeLessThan(60); + + // A caller-supplied depth cap tightens the bound further. + const shallowFn = vtkColorTransferFunction.newInstance(); + expect(compose([fn], colorFn, shallowFn, 0.01, 3)).toBeGreaterThan(0.01); + expect(shallowFn.getSize()).toBeGreaterThan(2); + // Depth 3 allows at most 2^3 - 1 = 7 extra nodes per segment. + expect(shallowFn.getSize()).toBeLessThanOrEqual(2 + 7); + expect(shallowFn.getSize()).toBeLessThan(size); +}); + +it('Test compose clears previously composed points on subsequent calls', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 0); + fn.addPoint(100, 100); + + const colorFn = vtkColorTransferFunction.newInstance(); + colorFn.addRGBPoint(0, 1, 0, 0); + colorFn.addRGBPoint(50, 0, 1, 0); + colorFn.addRGBPoint(100, 0, 0, 1); + + const outputFn = vtkColorTransferFunction.newInstance(); + compose([fn], colorFn, outputFn); + expect(outputFn.getSize()).toBe(3); + + colorFn.removeAllPoints(); + colorFn.addRGBPoint(0, 1, 1, 1); + colorFn.addRGBPoint(100, 0, 0, 0); + compose([fn], colorFn, outputFn); + expect(outputFn.getSize()).toBe(2); +}); From 2e73c4dd8b3b683c47d882f7a2a20d2a284d99a3 Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Wed, 15 Jul 2026 19:04:22 -0400 Subject: [PATCH 4/6] docs(piecewise-function): drop vestigial 'Unknown' from getType comment The 'Unknown (Error condition)' entry was carried over from the VTK/C++ header, but in this implementation functionType can only be 0-3 and the default case returns 'Varied'; the .d.ts union already reflects that. Note the divergence from C++ instead. Co-Authored-By: Claude Fable 5 --- Sources/Common/DataModel/PiecewiseFunction/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Common/DataModel/PiecewiseFunction/index.js b/Sources/Common/DataModel/PiecewiseFunction/index.js index 4cc4cb4d96f..aeaec10484f 100644 --- a/Sources/Common/DataModel/PiecewiseFunction/index.js +++ b/Sources/Common/DataModel/PiecewiseFunction/index.js @@ -19,8 +19,9 @@ function vtkPiecewiseFunction(publicAPI, model) { // 1 : NonDecreasing (Always increasing or zero slope) // 2 : NonIncreasing (Always decreasing or zero slope) // 3 : Varied (Contains both decreasing and increasing slopes) - // 4 : Unknown (Error condition) // + // Unlike VTK/C++ there is no 'Unknown' error state: functionType can + // only ever be one of the four values above. publicAPI.getType = () => { let value; let prevValue = 0.0; From dbf3702131c0a1724ff18fee38d384ddf835eacd Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Wed, 15 Jul 2026 19:04:37 -0400 Subject: [PATCH 5/6] feat(compose-functions): add example composing multiple piecewise functions DICOM slice viewer demonstrating the DICOM value transform pipeline: modality, VOI, and interactive window/level transforms are stored as vtkPiecewiseFunction instances and chained with a color map preset via the PiecewiseFunction compose() helper into a single RGB transfer function applied to the image slice. Files are decoded with itk-wasm loaded on demand; the transform parameters are driven by lil-gui controls. Co-Authored-By: Claude Fable 5 --- .../ComposePiecewiseFunctions/index.js | 430 ++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 Examples/Rendering/ComposePiecewiseFunctions/index.js diff --git a/Examples/Rendering/ComposePiecewiseFunctions/index.js b/Examples/Rendering/ComposePiecewiseFunctions/index.js new file mode 100644 index 00000000000..a274590f821 --- /dev/null +++ b/Examples/Rendering/ComposePiecewiseFunctions/index.js @@ -0,0 +1,430 @@ +import '@kitware/vtk.js/favicon'; + +// Load the rendering pieces we want to use (for both WebGL and WebGPU) +import '@kitware/vtk.js/Rendering/Profiles/Volume'; + +import Constants from '@kitware/vtk.js/Rendering/Core/ImageMapper/Constants'; +import vtkFullScreenRenderWindow from '@kitware/vtk.js/Rendering/Misc/FullScreenRenderWindow'; +import vtkImageMapper from '@kitware/vtk.js/Rendering/Core/ImageMapper'; +import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; +import vtkInteractorStyleImage from '@kitware/vtk.js/Interaction/Style/InteractorStyleImage'; +import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; +import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; +import { compose } from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction/helpers'; +import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; +import vtkResourceLoader from '@kitware/vtk.js/IO/Core/ResourceLoader'; +import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps'; + +import GUI from 'lil-gui'; + +const { SlicingMode } = Constants; + +// ---------------------------------------------------------------------------- +// Rendering setup +// ---------------------------------------------------------------------------- + +const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance({ + background: [0.1, 0.1, 0.1], +}); +const renderer = fullScreenRenderer.getRenderer(); +const renderWindow = fullScreenRenderer.getRenderWindow(); + +const mapper = vtkImageMapper.newInstance(); +mapper.setSlicingMode(SlicingMode.K); +mapper.setSliceAtFocalPoint(true); + +const actor = vtkImageSlice.newInstance(); +actor.setMapper(mapper); + +const iStyle = vtkInteractorStyleImage.newInstance(); +iStyle.setInteractionMode('IMAGE_SLICING'); +renderWindow.getInteractor().setInteractorStyle(iStyle); + +// ---------------------------------------------------------------------------- +// Piecewise function composition — DICOM value transform pipeline +// +// Transforms are chained in order and stored as piecewise linear functions: +// modalityFn — modality LUT (maps raw storage values to manufacturer units) +// voiFn — values-of-interest / window-level (maps units to display range) +// userFn — interactive user adjustments (window / level ramp) +// +// The composed result is stored in resultFn and applied to the actor. +// ---------------------------------------------------------------------------- + +let modalityFn = vtkPiecewiseFunction.newInstance(); +let voiFn = vtkPiecewiseFunction.newInstance(); +const userFn = vtkPiecewiseFunction.newInstance(); +const colorFn = vtkColorTransferFunction.newInstance(); +const resultFn = vtkColorTransferFunction.newInstance(); + +/** + * Output range [minY, maxY] of the given function. Segments are monotonic + * between their endpoint values, so the extremes always sit on nodes. + * @param {vtkPiecewiseFunction} fn + * @returns Output range of the given function as a [min, max] tuple. + */ +function getOutputRange(fn) { + const data = fn.getDataPointer(); + if (!data || data.length === 0) { + return [0, 0]; + } + let minY = Infinity; + let maxY = -Infinity; + for (let i = 1; i < data.length; i += 2) { + if (data[i] < minY) minY = data[i]; + if (data[i] > maxY) maxY = data[i]; + } + return [minY, maxY]; +} + +function printFnRange(fn, name) { + const inputRange = fn.getRange(); + const outputRange = getOutputRange(fn); + console.log( + `fn:${name} in-range: ${inputRange[0]}, ${inputRange[1]}, out-range: ${outputRange[0]}, ${outputRange[1]}` + ); +} + +/** + * Build a typically used shift-scale function as a vtkPiecewiseFunction. + * @param {*} dataRange + * @param {*} shift + * @param {*} scale + * @returns + */ +function buildShiftScaleFunction(dataRange, shift, scale) { + const [min, max] = dataRange; + const fn = vtkPiecewiseFunction.newInstance(); + fn.removeAllPoints(); + fn.addPoint(min, min * scale + shift); + fn.addPoint(max, max * scale + shift); + fn.setClamping(true); + return fn; +} + +function buildModalityFunction(dataRange, shift, scale) { + modalityFn = buildShiftScaleFunction(dataRange, shift, scale); + printFnRange(modalityFn, 'modalityFn'); +} + +function buildVoiFn(dataRange, shift, scale) { + voiFn = buildShiftScaleFunction( + // [dataRange[0] + 1000, dataRange[1] - 1000], + [dataRange[0], dataRange[1]], + shift, + scale + ); + printFnRange(voiFn, 'voiFn'); +} + +function buildUserFn(dataRange, colorWindow, colorLevel) { + const [min, max] = dataRange; + const lo = Math.max(min, colorLevel - colorWindow * 0.5); + const hi = Math.min(max, colorLevel + colorWindow * 0.5); + const colorFunctionXRange = colorFn.getRange(); + userFn.removeAllPoints(); + userFn.addPoint(lo, colorFunctionXRange[0]); + userFn.addPoint(hi, colorFunctionXRange[1]); + printFnRange(userFn, 'userFn'); +} + +function buildColorFunction(presetName) { + colorFn.removeAllPoints(); + colorFn.applyColorMap(vtkColorMaps.getPresetByName(presetName)); +} + +function example_recompose() { + const fnList = [modalityFn, voiFn, userFn]; + compose(fnList, colorFn, resultFn); + actor.getProperty().setUseLookupTableScalarRange(true); + actor.getProperty().setRGBTransferFunction(0, resultFn); +} + +// ---------------------------------------------------------------------------- +// Camera helpers +// ---------------------------------------------------------------------------- + +function resetCamera() { + const camera = renderer.getActiveCamera(); + camera.setParallelProjection(true); + const [cx, cy, cz] = mapper.getInputData().getCenter(); + camera.setFocalPoint(cx, cy, cz); + const normal = mapper.getSlicingModeNormal(); + camera.setPosition(cx - normal[0], cy - normal[1], cz - normal[2]); + camera.setViewUp(0, -1, 0); + renderer.resetCamera(); +} + +// ---------------------------------------------------------------------------- +// Load overlay (visible before any file is loaded) +// ---------------------------------------------------------------------------- + +const body = document.querySelector('body'); + +const loadOverlay = document.createElement('div'); +Object.assign(loadOverlay.style, { + position: 'absolute', + top: '0', + left: '0', + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + background: 'rgba(0,0,0,0.75)', + zIndex: '10', + color: '#fff', + fontFamily: 'sans-serif', +}); + +const loadTitle = document.createElement('p'); +loadTitle.innerText = 'Compose Piecewise Functions — DICOM Viewer'; +Object.assign(loadTitle.style, { fontSize: '18px', marginBottom: '16px' }); + +const loadButton = document.createElement('button'); +loadButton.innerText = 'Load DICOM File'; +Object.assign(loadButton.style, { + padding: '10px 24px', + fontSize: '15px', + cursor: 'pointer', + borderRadius: '4px', + border: 'none', + background: '#4a90e2', + color: '#fff', +}); + +const statusText = document.createElement('p'); +statusText.style.marginTop = '12px'; +statusText.style.fontSize = '13px'; +statusText.innerText = ''; + +const fileInput = document.createElement('input'); +fileInput.type = 'file'; +fileInput.accept = '.dcm,application/dicom,.nrrd'; +fileInput.style.display = 'none'; + +loadButton.addEventListener('click', () => fileInput.click()); + +loadOverlay.appendChild(loadTitle); +loadOverlay.appendChild(loadButton); +loadOverlay.appendChild(statusText); +loadOverlay.appendChild(fileInput); +body.appendChild(loadOverlay); + +// ---------------------------------------------------------------------------- +// GUI controls (built after a file is loaded) +// ---------------------------------------------------------------------------- + +let gui = null; + +function buildGui(dataRange, colorWindow, colorLevel) { + if (gui) { + gui.destroy(); + } + gui = new GUI({ title: 'Piecewise Function Controls' }); + + const params = { + scalarRange: `[${dataRange[0]}, ${dataRange[1]}]`, + modalityShift: 100, + modalityScale: 0.8, + voiShift: 50, + voiScale: 0.9, + colorWindow, + colorLevel, + preset: vtkColorMaps.rgbPresetNames[2], + loadNewFile: () => { + loadOverlay.style.display = 'flex'; + statusText.innerText = ''; + loadButton.innerText = 'Load DICOM File'; + loadButton.disabled = false; + fileInput.value = ''; + gui.hide(); + }, + }; + + gui.add(params, 'scalarRange').name('Scalar range').disable(); + + // Initial build of the color transfer function. + buildColorFunction(params.preset); + + // Modality transform + buildModalityFunction(dataRange, params.modalityShift, params.modalityScale); + // Values of interest transform + buildVoiFn(dataRange, params.voiShift, params.voiScale); + // User interactive adjustment (window/level) + buildUserFn(getOutputRange(voiFn), params.colorWindow, params.colorLevel); + // Compose into a single transfer function to feed into the mapper. + example_recompose(); + + function onModalityChange() { + buildModalityFunction( + dataRange, + params.modalityShift, + params.modalityScale + ); + example_recompose(); + renderWindow.render(); + } + + function onUserFnChange() { + buildUserFn(getOutputRange(voiFn), params.colorWindow, params.colorLevel); + example_recompose(); + renderWindow.render(); + } + + // ---- Modality transform ---- + const modalityFolder = gui.addFolder('Modality transform'); + modalityFolder + .add(params, 'modalityShift', -500, 500, 1) + .name('Shift') + .onChange(onModalityChange); + modalityFolder + .add(params, 'modalityScale', 0.1, 2.0, 0.01) + .name('Scale') + .onChange(onModalityChange); + + // ---- User adjustments (window / level) ---- + // The folder is created after the VOI folder below, but the controllers + // are declared here so the VOI callbacks can update their ranges. + let windowController; + let levelController; + + function syncUserFnControllerRanges() { + const [voiMin, voiMax] = getOutputRange(voiFn); + const span = voiMax - voiMin; + params.colorWindow = Math.min(Math.max(params.colorWindow, 1), span); + params.colorLevel = Math.min(Math.max(params.colorLevel, voiMin), voiMax); + windowController.min(1).max(span).updateDisplay(); + levelController.min(voiMin).max(voiMax).updateDisplay(); + } + + function onVoiChange() { + buildVoiFn(dataRange, params.voiShift, params.voiScale); + syncUserFnControllerRanges(); + buildUserFn(getOutputRange(voiFn), params.colorWindow, params.colorLevel); + example_recompose(); + renderWindow.render(); + } + + // ---- VOI transform ---- + const voiFolder = gui.addFolder('VOI transform (values of interest)'); + voiFolder + .add(params, 'voiShift', -500, 500, 1) + .name('Shift') + .onChange(onVoiChange); + voiFolder + .add(params, 'voiScale', 0.1, 2.0, 0.01) + .name('Scale') + .onChange(onVoiChange); + + const userFolder = gui.addFolder('User adjustments (window / level)'); + const [voiMin, voiMax] = getOutputRange(voiFn); + windowController = userFolder + .add(params, 'colorWindow', 1, voiMax - voiMin, 1) + .name('Window') + .onChange(onUserFnChange); + levelController = userFolder + .add(params, 'colorLevel', voiMin, voiMax, 1) + .name('Level') + .onChange(onUserFnChange); + + // ---- Color map ---- + gui + .add(params, 'preset', vtkColorMaps.rgbPresetNames) + .name('Color map') + .onChange((presetName) => { + buildColorFunction(presetName); + example_recompose(); + renderWindow.render(); + }); + + gui.add(params, 'loadNewFile').name('Load New File'); +} + +// ---------------------------------------------------------------------------- +// DICOM load + render +// ---------------------------------------------------------------------------- + +function renderDicom(file) { + statusText.innerText = 'Reading file…'; + const reader = new FileReader(); + reader.onload = async (e) => { + statusText.innerText = 'Decoding DICOM…'; + const { image: itkImage, webWorker } = + await window.itk.readImageArrayBuffer(null, e.target.result, file.name); + webWorker.terminate(); + + const imageData = vtkITKHelper.convertItkToVtkImage(itkImage); + mapper.setInputData(imageData); + + const scalars = imageData.getPointData().getScalars(); + const dataRange = scalars.getRange(); + + const colorWindow = (dataRange[1] - dataRange[0]) / 4.0; + const colorLevel = Math.round((dataRange[0] + dataRange[1]) / 2); + + buildGui(dataRange, colorWindow, colorLevel); + + if (!renderer.getActors().length) { + renderer.addActor(actor); + } + resetCamera(); + renderWindow.render(); + + // Hide overlay, show controls + loadOverlay.style.display = 'none'; + gui.show(); + }; + reader.readAsArrayBuffer(file); +} + +// ---------------------------------------------------------------------------- +// itk-wasm bootstrap +// ---------------------------------------------------------------------------- + +let itkReady = false; + +fileInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + + if (itkReady) { + renderDicom(file); + } else { + loadButton.innerText = 'Loading itk-wasm…'; + loadButton.disabled = true; + statusText.innerText = 'Downloading DICOM decoder…'; + vtkResourceLoader + .loadScript( + 'https://cdn.jsdelivr.net/npm/itk-wasm@1.0.0-b.8/dist/umd/itk-wasm.js' + ) + .then(() => { + itkReady = true; + renderDicom(file); + }); + } +}); + +// Pre-fetch itk-wasm in the background so first load is faster +vtkResourceLoader + .loadScript( + 'https://cdn.jsdelivr.net/npm/itk-wasm@1.0.0-b.8/dist/umd/itk-wasm.js' + ) + .then(() => { + itkReady = true; + }); + +// ----------------------------------------------------------- +// Global references for browser console inspection +// ----------------------------------------------------------- + +global.mapper = mapper; +global.actor = actor; +global.renderer = renderer; +global.renderWindow = renderWindow; +global.modalityFn = modalityFn; +global.voiFn = voiFn; +global.userFn = userFn; +global.resultFn = resultFn; +global.colorFn = colorFn; From fa3dd36896504aeddd76a38b6beafd8ba485874a Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Thu, 16 Jul 2026 13:31:10 -0400 Subject: [PATCH 6/6] fix(piecewise-function): correct getNodeValue/setNodeValue typings getNodeValue was declared to return void but the implementation returns -1 (index out of range) or 1 (success); type both node accessors as -1 | 1, narrow val from any[] to number[], and document the return values. Co-Authored-By: Claude Fable 5 --- Sources/Common/DataModel/PiecewiseFunction/index.d.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/Common/DataModel/PiecewiseFunction/index.d.ts b/Sources/Common/DataModel/PiecewiseFunction/index.d.ts index 4676cc39124..5f5a388dd2a 100644 --- a/Sources/Common/DataModel/PiecewiseFunction/index.d.ts +++ b/Sources/Common/DataModel/PiecewiseFunction/index.d.ts @@ -98,12 +98,13 @@ export interface vtkPiecewiseFunction extends vtkObject { findX(y: number): number | null; /** - * For the node specified by index, set/get the location (X), value (Y), + * For the node specified by index, get the location (X), value (Y), * midpoint, and sharpness values at the node. * @param {Number} index * @param val + * @returns -1 if index is out of range, returns 1 otherwise. */ - getNodeValue(index: number, val: any[]): void; + getNodeValue(index: number, val: number[]): -1 | 1; /** * Returns the min and max node locations of the function. @@ -182,11 +183,13 @@ export interface vtkPiecewiseFunction extends vtkObject { setClamping(clamping: boolean): boolean; /** - * + * For the node specified by index, set the location (X), value (Y), + * midpoint, and sharpness values at the node. * @param {Number} index * @param val + * @returns -1 if index is out of range, returns 1 otherwise. */ - setNodeValue(index: number, val: any[]): number; + setNodeValue(index: number, val: number[]): -1 | 1; /** *