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; 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/index.d.ts b/Sources/Common/DataModel/PiecewiseFunction/index.d.ts index 5b52ff93c9d..5f5a388dd2a 100644 --- a/Sources/Common/DataModel/PiecewiseFunction/index.d.ts +++ b/Sources/Common/DataModel/PiecewiseFunction/index.d.ts @@ -85,12 +85,26 @@ export interface vtkPiecewiseFunction extends vtkObject { getFirstNonZeroValue(): number; /** - * For the node specified by index, set/get the location (X), value (Y), + * 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, 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. @@ -169,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; /** * diff --git a/Sources/Common/DataModel/PiecewiseFunction/index.js b/Sources/Common/DataModel/PiecewiseFunction/index.js index 56d34d1d344..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; @@ -338,6 +339,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/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); +}); 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(); +}); 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; + }; } // ----------------------------------------------------------------------------