From ec63470fcfba9399a8fd61060595dc361696ca13 Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Thu, 23 Apr 2026 14:51:47 -0400 Subject: [PATCH 1/5] feat(compose-functions): add example for testing composition of multiple piecewise functions This example tests a feature that allows composition of multiple piecewise functions into a single function which results in a single color transfer function. Co-Authored-By: Claude Sonnet 4.6 --- .../ComposePiecewiseFunctions/index.js | 710 ++++++++++++++++++ 1 file changed, 710 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..560dfa5850f --- /dev/null +++ b/Examples/Rendering/ComposePiecewiseFunctions/index.js @@ -0,0 +1,710 @@ +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 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'; + +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(); + +/** + * + * @param {vtkPiecewiseFunction} fn + * @returns Output range of the given function as a tuple, using its maximum input range. + */ +function getOutputRange(fn) { + const inputRange = fn.getRange(); + return [fn.getValue(inputRange[0]), fn.getValue(inputRange[1])]; +} + +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 getColorFunctionXValues(cfun) { + const ret = []; + const v = [0, 0, 0, 0, 0, 0]; + for (let i = 0; i < cfun.getSize(); i++) { + if (cfun.getNodeValue(i, v) === 1) { + ret.push(v[0]); + } + } + return ret; +} + +// Given a piecewise linear fn and a y value, return the x such that fn(x) = y. +// Walks each segment and linearly interpolates within the first matching one. +// Returns null if y is outside the function's output range. +// TODO: This function can become a public member of vtkPiecewiseFunction +function invertFn(fn, y) { + const data = fn.getDataPointer(); + if (!data || data.length < 4) return null; + for (let i = 0; i < data.length - 2; i += 2) { + const x0 = data[i]; + const y0 = data[i + 1]; + const x1 = data[i + 2]; + const y1 = data[i + 3]; + /* eslint-disable no-continue */ + if (y0 === y1) { + continue; + } + const minY = Math.min(y0, y1); + const maxY = Math.max(y0, y1); + if (y >= minY && y <= maxY) { + return x0 + ((y - y0) / (y1 - y0)) * (x1 - x0); + } + } + + // below range case + if (fn.getClamping()) { + if (y <= data[1]) { + // x-position of first node + return data[0]; + } + if (y >= data[data.length - 1]) { + // x-position of last node + return data[data.length - 2]; + } + } + + console.warn(`invertFn: null returned for: ${fn.getState()}, ${y}`); + return null; +} + +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(dataRange) { + const fnList = [modalityFn, voiFn, userFn]; + recompose(dataRange, fnList, resultFn); + actor.getProperty().setUseLookupTableScalarRange(true); + actor.getProperty().setRGBTransferFunction(0, resultFn); +} + +// Collect all x-positions across all transform functions and chain their outputs. +// getDataPointer() returns a flat [x0,y0,x1,y1,...] array. +// Make sure the texture size doesn't get exceeded. +function recompose(dataRange, fnList, outputFn) { + // const fnList = [modalityFn, voiFn, userFn]; + const xSet = new Set(); + + // Each function's breakpoint x-values live in that function's input domain. + // For h(g(f(x))), g's x-values are in f's output domain and must be pulled + // back through f-inverse; h's x-values need g-inverse then f-inverse. + fnList.forEach((fn, idx) => { + const data = fn.getDataPointer(); + if (!data) return; + for (let i = 0; i < data.length; i += 2) { + let x = data[i]; + for (let j = idx - 1; j >= 0; j--) { + x = invertFn(fnList[j], x); + if (x == null) break; + } + 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) => { + let t = x; + for (let j = fnList.length - 1; j >= 0; j--) { + t = invertFn(fnList[j], t); + if (t == null) break; + } + if (t != null) xSet.add(t); + }); + + // Also add range endpoints so the composed function spans the full range + // xSet.add(dataRange[0]); + // xSet.add(dataRange[1]); + + const xs = Array.from(xSet).sort((a, b) => a - b); + console.log(`xs: ${xs}`); + outputFn.removeAllPoints(); + xs.forEach((x) => { + const finalScalar = fnList.reduce((val, fn) => fn.getValue(val), x); + const rgb = [0, 0, 0]; + colorFn.getColor(finalScalar, rgb); + outputFn.addRGBPoint(x, rgb[0], rgb[1], rgb[2]); + console.log(`nodes: x:${x}, y:${finalScalar} --> rgb: ${rgb}`); + }); + + outputFn.setRange(...dataRange); + outputFn.setMappingRange(...dataRange); + outputFn.updateRange(); + outputFn.modified(); + console.log(`final func: ${outputFn.getState()}`); +} + +// ---------------------------------------------------------------------------- +// 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(); +} + +// ---------------------------------------------------------------------------- +// UI helpers +// ---------------------------------------------------------------------------- + +const body = document.querySelector('body'); + +function makeSlider( + label, + min, + max, + value, + step, + onChange, + format = Math.round +) { + const wrap = document.createElement('div'); + wrap.style.marginBottom = '10px'; + + const lbl = document.createElement('label'); + lbl.style.display = 'block'; + lbl.style.marginBottom = '2px'; + + const valueSpan = document.createElement('span'); + valueSpan.innerText = ` ${format(value)}`; + + lbl.appendChild(document.createTextNode(label)); + lbl.appendChild(valueSpan); + + const input = document.createElement('input'); + input.type = 'range'; + input.min = min; + input.max = max; + input.value = value; + input.step = step; + input.style.width = '100%'; + + input.addEventListener('input', () => { + valueSpan.innerText = ` ${format(Number(input.value))}`; + onChange(Number(input.value)); + }); + + wrap.appendChild(lbl); + wrap.appendChild(input); + return { wrap, input }; +} + +// ---------------------------------------------------------------------------- +// Load overlay (visible before any file is loaded) +// ---------------------------------------------------------------------------- + +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); + +// ---------------------------------------------------------------------------- +// Controls panel (visible after a file is loaded) +// ---------------------------------------------------------------------------- + +const controlPanel = document.createElement('div'); +Object.assign(controlPanel.style, { + position: 'absolute', + top: '10px', + right: '10px', + background: 'rgba(0,0,0,0.7)', + color: '#fff', + fontFamily: 'sans-serif', + fontSize: '13px', + padding: '12px', + borderRadius: '6px', + display: 'none', + minWidth: '230px', + zIndex: '5', +}); +body.appendChild(controlPanel); + +// ---------------------------------------------------------------------------- +// 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); + + let modalityShift = 100; + let modalityScale = 0.8; + let voiShift = 50; + let voiScale = 0.9; + let currentPreset = vtkColorMaps.rgbPresetNames[2]; + + // Modality transform + buildModalityFunction(dataRange, modalityShift, modalityScale); + // Values of interest transform + buildVoiFn(dataRange, voiShift, voiScale); + // User interactive adjustment (window/level) + buildUserFn(getOutputRange(voiFn), colorWindow, colorLevel); + buildColorFunction(currentPreset); + // Compose into a single transferfunction to feed into the mapper. + example_recompose(dataRange); + + if (!renderer.getActors().length) { + renderer.addActor(actor); + } + resetCamera(); + renderWindow.render(); + + // Hide overlay, populate and show controls + loadOverlay.style.display = 'none'; + controlPanel.innerHTML = ''; + + const heading = document.createElement('div'); + heading.innerText = 'Piecewise Function Controls'; + Object.assign(heading.style, { + fontWeight: 'bold', + marginBottom: '6px', + fontSize: '14px', + }); + controlPanel.appendChild(heading); + + const rangeLabel = document.createElement('div'); + rangeLabel.innerText = `Scalar range: [${dataRange[0]}, ${dataRange[1]}]`; + Object.assign(rangeLabel.style, { + fontSize: '11px', + color: '#aaa', + marginBottom: '10px', + }); + controlPanel.appendChild(rangeLabel); + + const scaleFormat = (v) => v.toFixed(2); + + // ---- Modality transform ---- + const modalityHeading = document.createElement('div'); + modalityHeading.innerText = 'Modality transform'; + Object.assign(modalityHeading.style, { + fontSize: '11px', + color: '#aaa', + marginBottom: '4px', + }); + controlPanel.appendChild(modalityHeading); + + const { input: modalityShiftInput } = makeSlider( + 'Shift:', + -500, + 500, + modalityShift, + 1, + (val) => { + modalityShift = val; + buildModalityFunction(dataRange, modalityShift, modalityScale); + example_recompose(dataRange); + renderWindow.render(); + } + ); + controlPanel.appendChild(modalityShiftInput.parentElement); + + const { input: modalityScaleInput } = makeSlider( + 'Scale:', + 0.1, + 2.0, + modalityScale, + 0.01, + (val) => { + modalityScale = val; + buildModalityFunction(dataRange, modalityShift, modalityScale); + example_recompose(dataRange); + renderWindow.render(); + }, + scaleFormat + ); + controlPanel.appendChild(modalityScaleInput.parentElement); + + // Pre-declare so VOI callbacks can close over them before User section runs + let windowInput; + let levelInput; + + function syncUserFnSliderRanges() { + const [voiMin, voiMax] = getOutputRange(voiFn); + const span = voiMax - voiMin; + windowInput.min = 1; + windowInput.max = span; + windowInput.value = Math.min( + Math.max(Number(windowInput.value), 1), + span + ); + levelInput.min = voiMin; + levelInput.max = voiMax; + levelInput.value = Math.min( + Math.max(Number(levelInput.value), voiMin), + voiMax + ); + } + + // ---- VOI transform ---- + const composeHeading = document.createElement('div'); + composeHeading.innerText = 'VOI transform (values of interest)'; + Object.assign(composeHeading.style, { + fontSize: '11px', + color: '#aaa', + margin: '8px 0 4px', + }); + controlPanel.appendChild(composeHeading); + + const { input: voiShiftInput } = makeSlider( + 'Shift:', + -500, + 500, + voiShift, + 1, + (val) => { + voiShift = val; + buildVoiFn(dataRange, voiShift, voiScale); + syncUserFnSliderRanges(); + buildUserFn( + getOutputRange(voiFn), + Number(windowInput.value), + Number(levelInput.value) + ); + example_recompose(dataRange); + renderWindow.render(); + } + ); + controlPanel.appendChild(voiShiftInput.parentElement); + + const { input: voiScaleInput } = makeSlider( + 'Scale:', + 0.1, + 2.0, + voiScale, + 0.01, + (val) => { + voiScale = val; + buildVoiFn(dataRange, voiShift, voiScale); + syncUserFnSliderRanges(); + buildUserFn( + getOutputRange(voiFn), + Number(windowInput.value), + Number(levelInput.value) + ); + example_recompose(dataRange); + renderWindow.render(); + }, + scaleFormat + ); + controlPanel.appendChild(voiScaleInput.parentElement); + + // ---- User adjustments ---- + const wlHeading = document.createElement('div'); + wlHeading.innerText = 'User adjustments (window / level)'; + Object.assign(wlHeading.style, { + fontSize: '11px', + color: '#aaa', + margin: '8px 0 4px', + }); + controlPanel.appendChild(wlHeading); + + ({ input: windowInput } = makeSlider( + 'Window:', + 1, + getOutputRange(voiFn)[1] - getOutputRange(voiFn)[0], + colorWindow, + 1, + (val) => { + buildUserFn(getOutputRange(voiFn), val, Number(levelInput.value)); + buildColorFunction(currentPreset); + example_recompose(dataRange); + renderWindow.render(); + } + )); + controlPanel.appendChild(windowInput.parentElement); + + ({ input: levelInput } = makeSlider( + 'Level:', + getOutputRange(voiFn)[0], + getOutputRange(voiFn)[1], + colorLevel, + 1, + (val) => { + buildUserFn(getOutputRange(voiFn), Number(windowInput.value), val); + buildColorFunction(currentPreset); + example_recompose(dataRange); + renderWindow.render(); + } + )); + controlPanel.appendChild(levelInput.parentElement); + + // ---- Color map ---- + const colorMapHeading = document.createElement('div'); + colorMapHeading.innerText = 'Color map'; + Object.assign(colorMapHeading.style, { + fontSize: '11px', + color: '#aaa', + margin: '8px 0 4px', + }); + controlPanel.appendChild(colorMapHeading); + + const presetSelector = document.createElement('select'); + presetSelector.innerHTML = vtkColorMaps.rgbPresetNames + .map( + (name) => + `` + ) + .join(''); + Object.assign(presetSelector.style, { + width: '100%', + background: '#333', + color: '#fff', + border: '1px solid #555', + borderRadius: '3px', + padding: '3px', + fontSize: '12px', + }); + presetSelector.addEventListener('change', () => { + currentPreset = presetSelector.value; + buildColorFunction(currentPreset); + example_recompose(dataRange); + renderWindow.render(); + }); + controlPanel.appendChild(presetSelector); + + // ---- Reload button ---- + const reloadBtn = document.createElement('button'); + reloadBtn.innerText = 'Load New File'; + Object.assign(reloadBtn.style, { + marginTop: '10px', + padding: '6px 12px', + cursor: 'pointer', + background: '#4a90e2', + color: '#fff', + border: 'none', + borderRadius: '4px', + width: '100%', + }); + reloadBtn.addEventListener('click', () => { + loadOverlay.style.display = 'flex'; + statusText.innerText = ''; + loadButton.innerText = 'Load DICOM File'; + loadButton.disabled = false; + controlPanel.style.display = 'none'; + fileInput.value = ''; + }); + controlPanel.appendChild(reloadBtn); + + controlPanel.style.display = 'block'; + }; + 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 c220eac81f8b234e84ca65de3dec0335d4dcedea Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Wed, 10 Jun 2026 13:38:45 -0400 Subject: [PATCH 2/5] feat(compose-functions): add helpers to PiecewiseFunction Move the piecewise function composition logic out of the ComposePiecewiseFunctions example into a reusable, exported compose() helper in Common/DataModel/PiecewiseFunction/helpers.js. Add findX() as a public method on vtkPiecewiseFunction, replacing the example local invertFn implementation. Co-Authored-By: Claude Sonnet 4.6 --- .../ComposePiecewiseFunctions/index.js | 111 +----------------- .../DataModel/PiecewiseFunction/helpers.js | 70 +++++++++++ .../DataModel/PiecewiseFunction/index.d.ts | 8 ++ .../DataModel/PiecewiseFunction/index.js | 35 ++++++ 4 files changed, 115 insertions(+), 109 deletions(-) create mode 100644 Sources/Common/DataModel/PiecewiseFunction/helpers.js diff --git a/Examples/Rendering/ComposePiecewiseFunctions/index.js b/Examples/Rendering/ComposePiecewiseFunctions/index.js index 560dfa5850f..feef09b3efc 100644 --- a/Examples/Rendering/ComposePiecewiseFunctions/index.js +++ b/Examples/Rendering/ComposePiecewiseFunctions/index.js @@ -10,6 +10,7 @@ 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'; @@ -89,56 +90,6 @@ function buildShiftScaleFunction(dataRange, shift, scale) { return fn; } -function getColorFunctionXValues(cfun) { - const ret = []; - const v = [0, 0, 0, 0, 0, 0]; - for (let i = 0; i < cfun.getSize(); i++) { - if (cfun.getNodeValue(i, v) === 1) { - ret.push(v[0]); - } - } - return ret; -} - -// Given a piecewise linear fn and a y value, return the x such that fn(x) = y. -// Walks each segment and linearly interpolates within the first matching one. -// Returns null if y is outside the function's output range. -// TODO: This function can become a public member of vtkPiecewiseFunction -function invertFn(fn, y) { - const data = fn.getDataPointer(); - if (!data || data.length < 4) return null; - for (let i = 0; i < data.length - 2; i += 2) { - const x0 = data[i]; - const y0 = data[i + 1]; - const x1 = data[i + 2]; - const y1 = data[i + 3]; - /* eslint-disable no-continue */ - if (y0 === y1) { - continue; - } - const minY = Math.min(y0, y1); - const maxY = Math.max(y0, y1); - if (y >= minY && y <= maxY) { - return x0 + ((y - y0) / (y1 - y0)) * (x1 - x0); - } - } - - // below range case - if (fn.getClamping()) { - if (y <= data[1]) { - // x-position of first node - return data[0]; - } - if (y >= data[data.length - 1]) { - // x-position of last node - return data[data.length - 2]; - } - } - - console.warn(`invertFn: null returned for: ${fn.getState()}, ${y}`); - return null; -} - function buildModalityFunction(dataRange, shift, scale) { modalityFn = buildShiftScaleFunction(dataRange, shift, scale); printFnRange(modalityFn, 'modalityFn'); @@ -172,69 +123,11 @@ function buildColorFunction(presetName) { function example_recompose(dataRange) { const fnList = [modalityFn, voiFn, userFn]; - recompose(dataRange, fnList, resultFn); + compose(dataRange, fnList, colorFn, resultFn); actor.getProperty().setUseLookupTableScalarRange(true); actor.getProperty().setRGBTransferFunction(0, resultFn); } -// Collect all x-positions across all transform functions and chain their outputs. -// getDataPointer() returns a flat [x0,y0,x1,y1,...] array. -// Make sure the texture size doesn't get exceeded. -function recompose(dataRange, fnList, outputFn) { - // const fnList = [modalityFn, voiFn, userFn]; - const xSet = new Set(); - - // Each function's breakpoint x-values live in that function's input domain. - // For h(g(f(x))), g's x-values are in f's output domain and must be pulled - // back through f-inverse; h's x-values need g-inverse then f-inverse. - fnList.forEach((fn, idx) => { - const data = fn.getDataPointer(); - if (!data) return; - for (let i = 0; i < data.length; i += 2) { - let x = data[i]; - for (let j = idx - 1; j >= 0; j--) { - x = invertFn(fnList[j], x); - if (x == null) break; - } - 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) => { - let t = x; - for (let j = fnList.length - 1; j >= 0; j--) { - t = invertFn(fnList[j], t); - if (t == null) break; - } - if (t != null) xSet.add(t); - }); - - // Also add range endpoints so the composed function spans the full range - // xSet.add(dataRange[0]); - // xSet.add(dataRange[1]); - - const xs = Array.from(xSet).sort((a, b) => a - b); - console.log(`xs: ${xs}`); - outputFn.removeAllPoints(); - xs.forEach((x) => { - const finalScalar = fnList.reduce((val, fn) => fn.getValue(val), x); - const rgb = [0, 0, 0]; - colorFn.getColor(finalScalar, rgb); - outputFn.addRGBPoint(x, rgb[0], rgb[1], rgb[2]); - console.log(`nodes: x:${x}, y:${finalScalar} --> rgb: ${rgb}`); - }); - - outputFn.setRange(...dataRange); - outputFn.setMappingRange(...dataRange); - outputFn.updateRange(); - outputFn.modified(); - console.log(`final func: ${outputFn.getState()}`); -} - // ---------------------------------------------------------------------------- // Camera helpers // ---------------------------------------------------------------------------- diff --git a/Sources/Common/DataModel/PiecewiseFunction/helpers.js b/Sources/Common/DataModel/PiecewiseFunction/helpers.js new file mode 100644 index 00000000000..f6fd56164e9 --- /dev/null +++ b/Sources/Common/DataModel/PiecewiseFunction/helpers.js @@ -0,0 +1,70 @@ +function getColorFunctionXValues(cfun) { + const ret = []; + const v = [0, 0, 0, 0, 0, 0]; // [x, r, g, b, midpoint, sharpness] + for (let i = 0; i < cfun.getSize(); i++) { + if (cfun.getNodeValue(i, v) === 1) { + ret.push(v[0]); + } + } + return ret; +} + +/** + * 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. + * + * @param {Range} dataRange input data range to apply to the output function + * @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 + */ +export function compose(dataRange, fnList, colorFn, outputFn) { + const xSet = new Set(); + + // 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) { + let x = data[i]; + for (let j = idx - 1; j >= 0; j--) { + x = fnList[j].findX(x); + if (x == null) break; + } + 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) => { + let t = x; + for (let j = fnList.length - 1; j >= 0; j--) { + t = fnList[j].findX(t); + if (t == null) break; + } + if (t != null) xSet.add(t); + }); + + const xs = Array.from(xSet).sort((a, b) => a - b); + outputFn.removeAllPoints(); + xs.forEach((x) => { + const finalScalar = fnList.reduce((val, fn) => fn.getValue(val), x); + const rgb = [0, 0, 0]; + colorFn.getColor(finalScalar, rgb); + outputFn.addRGBPoint(x, rgb[0], rgb[1], rgb[2]); + }); + + outputFn.setRange(...dataRange); + outputFn.setMappingRange(...dataRange); + outputFn.updateRange(); + outputFn.modified(); +} diff --git a/Sources/Common/DataModel/PiecewiseFunction/index.d.ts b/Sources/Common/DataModel/PiecewiseFunction/index.d.ts index 5b52ff93c9d..749d2bf74b1 100644 --- a/Sources/Common/DataModel/PiecewiseFunction/index.d.ts +++ b/Sources/Common/DataModel/PiecewiseFunction/index.d.ts @@ -84,6 +84,14 @@ export interface vtkPiecewiseFunction extends vtkObject { */ getFirstNonZeroValue(): number; + /** + * Inverse of getValue(): given a value y, returns an x such that + * getValue(x) === y, linearly interpolating between nodes. Returns null + * if y is outside the function's output range and 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..0ad006976e4 100644 --- a/Sources/Common/DataModel/PiecewiseFunction/index.js +++ b/Sources/Common/DataModel/PiecewiseFunction/index.js @@ -338,6 +338,41 @@ function vtkPiecewiseFunction(publicAPI, model) { return table[0]; }; + // Inverse of getValue(): given a value y, return an x such that + // getValue(x) === y, walking each segment and linearly interpolating + // within the first matching one. Returns null if y is outside the + // function's output range and clamping is off. + publicAPI.findX = (y) => { + const { nodes } = model; + /* eslint-disable no-continue */ + 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 (y0 === y1) { + continue; + } + const minY = Math.min(y0, y1); + const maxY = Math.max(y0, y1); + if (y >= minY && y <= maxY) { + return x0 + ((y - y0) / (y1 - y0)) * (x1 - x0); + } + } + /* eslint-enable no-continue */ + + if (model.clamping && nodes.length > 0) { + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + if (y <= first.y) { + return first.x; + } + if (y >= last.y) { + return last.x; + } + } + + 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 From 479279d1acc5cda62b3f9c395f2cbe178de64149 Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Wed, 10 Jun 2026 14:57:26 -0400 Subject: [PATCH 3/5] test(compose-functions): add unit tests for findX and compose Cover vtkPiecewiseFunction.findX (linear interpolation, multi-segment, flat-segment handling, decreasing functions, clamping, empty function) and Common/DataModel/PiecewiseFunction/helpers.compose (identity passthrough, breakpoint inversion, multi-stage chaining, repeated calls). Co-Authored-By: Claude Sonnet 4.6 --- .../PiecewiseFunction/test/testHelpers.js | 126 ++++++++++++++++++ .../test/testPiecewiseFunction.js | 69 ++++++++++ 2 files changed, 195 insertions(+) create mode 100644 Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js create mode 100644 Sources/Common/DataModel/PiecewiseFunction/test/testPiecewiseFunction.js diff --git a/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js b/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js new file mode 100644 index 00000000000..f127756e051 --- /dev/null +++ b/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js @@ -0,0 +1,126 @@ +import { it, expect } from 'vitest'; +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(); + compose([0, 100], [identityFn], colorFn, outputFn); + + 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([0, 100], [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([0, 100], [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 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([0, 100], [fn], colorFn, outputFn); + expect(outputFn.getSize()).toBe(3); + + colorFn.removeAllPoints(); + colorFn.addRGBPoint(0, 1, 1, 1); + colorFn.addRGBPoint(100, 0, 0, 0); + compose([0, 100], [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..346af4ecc45 --- /dev/null +++ b/Sources/Common/DataModel/PiecewiseFunction/test/testPiecewiseFunction.js @@ -0,0 +1,69 @@ +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 skips zero-slope (flat) segments', () => { + const fn = vtkPiecewiseFunction.newInstance(); + fn.addPoint(0, 5); + fn.addPoint(5, 5); // flat segment from x=0 to x=5 + fn.addPoint(10, 15); + + // y=5 is the degenerate value of the flat segment as well as the start of + // the rising segment. The flat segment must be skipped to avoid a + // divide-by-zero (NaN) result. + expect(fn.findX(5)).toBeCloseTo(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 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 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 32407377892e8ef49cd7b249dd4c248ac3dc3eb5 Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Wed, 10 Jun 2026 17:48:34 -0400 Subject: [PATCH 4/5] fix(example): ComposePiecewiseFunctions color function initialization --- Examples/Rendering/ComposePiecewiseFunctions/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Examples/Rendering/ComposePiecewiseFunctions/index.js b/Examples/Rendering/ComposePiecewiseFunctions/index.js index feef09b3efc..f308dc36ea8 100644 --- a/Examples/Rendering/ComposePiecewiseFunctions/index.js +++ b/Examples/Rendering/ComposePiecewiseFunctions/index.js @@ -293,13 +293,15 @@ function renderDicom(file) { let voiScale = 0.9; let currentPreset = vtkColorMaps.rgbPresetNames[2]; + // Initial build of the color transfer function. + buildColorFunction(currentPreset); + // Modality transform buildModalityFunction(dataRange, modalityShift, modalityScale); // Values of interest transform buildVoiFn(dataRange, voiShift, voiScale); // User interactive adjustment (window/level) buildUserFn(getOutputRange(voiFn), colorWindow, colorLevel); - buildColorFunction(currentPreset); // Compose into a single transferfunction to feed into the mapper. example_recompose(dataRange); @@ -466,7 +468,6 @@ function renderDicom(file) { 1, (val) => { buildUserFn(getOutputRange(voiFn), val, Number(levelInput.value)); - buildColorFunction(currentPreset); example_recompose(dataRange); renderWindow.render(); } @@ -481,7 +482,6 @@ function renderDicom(file) { 1, (val) => { buildUserFn(getOutputRange(voiFn), Number(windowInput.value), val); - buildColorFunction(currentPreset); example_recompose(dataRange); renderWindow.render(); } From 7b3f85c382f65a473cf8ee33a225444fff4c7a9a Mon Sep 17 00:00:00 2001 From: Shreeraj Jadhav Date: Wed, 10 Jun 2026 18:03:46 -0400 Subject: [PATCH 5/5] refactor(compose-functions): drop unused dataRange param from compose() compose() no longer needs an explicit dataRange since outputFn's mapping range is already derived from its node positions via addRGBPoint's sortAndUpdateRange. Update the example and tests to match the new compose(fnList, colorFn, outputFn) signature. Co-Authored-By: Claude Sonnet 4.6 --- .../ComposePiecewiseFunctions/index.js | 20 +++++++++---------- .../DataModel/PiecewiseFunction/helpers.js | 11 ++++------ .../PiecewiseFunction/test/testHelpers.js | 10 +++++----- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/Examples/Rendering/ComposePiecewiseFunctions/index.js b/Examples/Rendering/ComposePiecewiseFunctions/index.js index f308dc36ea8..46aedf7404d 100644 --- a/Examples/Rendering/ComposePiecewiseFunctions/index.js +++ b/Examples/Rendering/ComposePiecewiseFunctions/index.js @@ -121,9 +121,9 @@ function buildColorFunction(presetName) { colorFn.applyColorMap(vtkColorMaps.getPresetByName(presetName)); } -function example_recompose(dataRange) { +function example_recompose() { const fnList = [modalityFn, voiFn, userFn]; - compose(dataRange, fnList, colorFn, resultFn); + compose(fnList, colorFn, resultFn); actor.getProperty().setUseLookupTableScalarRange(true); actor.getProperty().setRGBTransferFunction(0, resultFn); } @@ -303,7 +303,7 @@ function renderDicom(file) { // User interactive adjustment (window/level) buildUserFn(getOutputRange(voiFn), colorWindow, colorLevel); // Compose into a single transferfunction to feed into the mapper. - example_recompose(dataRange); + example_recompose(); if (!renderer.getActors().length) { renderer.addActor(actor); @@ -354,7 +354,7 @@ function renderDicom(file) { (val) => { modalityShift = val; buildModalityFunction(dataRange, modalityShift, modalityScale); - example_recompose(dataRange); + example_recompose(); renderWindow.render(); } ); @@ -369,7 +369,7 @@ function renderDicom(file) { (val) => { modalityScale = val; buildModalityFunction(dataRange, modalityShift, modalityScale); - example_recompose(dataRange); + example_recompose(); renderWindow.render(); }, scaleFormat @@ -422,7 +422,7 @@ function renderDicom(file) { Number(windowInput.value), Number(levelInput.value) ); - example_recompose(dataRange); + example_recompose(); renderWindow.render(); } ); @@ -443,7 +443,7 @@ function renderDicom(file) { Number(windowInput.value), Number(levelInput.value) ); - example_recompose(dataRange); + example_recompose(); renderWindow.render(); }, scaleFormat @@ -468,7 +468,7 @@ function renderDicom(file) { 1, (val) => { buildUserFn(getOutputRange(voiFn), val, Number(levelInput.value)); - example_recompose(dataRange); + example_recompose(); renderWindow.render(); } )); @@ -482,7 +482,7 @@ function renderDicom(file) { 1, (val) => { buildUserFn(getOutputRange(voiFn), Number(windowInput.value), val); - example_recompose(dataRange); + example_recompose(); renderWindow.render(); } )); @@ -519,7 +519,7 @@ function renderDicom(file) { presetSelector.addEventListener('change', () => { currentPreset = presetSelector.value; buildColorFunction(currentPreset); - example_recompose(dataRange); + example_recompose(); renderWindow.render(); }); controlPanel.appendChild(presetSelector); diff --git a/Sources/Common/DataModel/PiecewiseFunction/helpers.js b/Sources/Common/DataModel/PiecewiseFunction/helpers.js index f6fd56164e9..e0aaf688fdf 100644 --- a/Sources/Common/DataModel/PiecewiseFunction/helpers.js +++ b/Sources/Common/DataModel/PiecewiseFunction/helpers.js @@ -19,12 +19,11 @@ function getColorFunctionXValues(cfun) { * and must be pulled back through f-inverse; h's x-values need g-inverse then * f-inverse, and so on. * - * @param {Range} dataRange input data range to apply to the output function * @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 */ -export function compose(dataRange, fnList, colorFn, outputFn) { +export function compose(fnList, colorFn, outputFn) { const xSet = new Set(); // Each function's breakpoint x-values live in that function's input domain. @@ -54,6 +53,9 @@ export function compose(dataRange, fnList, colorFn, outputFn) { 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); outputFn.removeAllPoints(); xs.forEach((x) => { @@ -62,9 +64,4 @@ export function compose(dataRange, fnList, colorFn, outputFn) { colorFn.getColor(finalScalar, rgb); outputFn.addRGBPoint(x, rgb[0], rgb[1], rgb[2]); }); - - outputFn.setRange(...dataRange); - outputFn.setMappingRange(...dataRange); - outputFn.updateRange(); - outputFn.modified(); } diff --git a/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js b/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js index f127756e051..a404499d887 100644 --- a/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js +++ b/Sources/Common/DataModel/PiecewiseFunction/test/testHelpers.js @@ -23,7 +23,7 @@ it('Test compose with an identity transform passes colorFn through unchanged', ( colorFn.addRGBPoint(100, 0, 0, 1); const outputFn = vtkColorTransferFunction.newInstance(); - compose([0, 100], [identityFn], colorFn, outputFn); + compose([identityFn], colorFn, outputFn); expect(getNodeXs(outputFn)).toEqual([0, 100]); expect(outputFn.getRange()).toEqual([0, 100]); @@ -49,7 +49,7 @@ it('Test compose maps a color function breakpoint back to its source domain', () colorFn.addRGBPoint(110, 0, 0, 1); const outputFn = vtkColorTransferFunction.newInstance(); - compose([0, 100], [fn], colorFn, outputFn); + compose([fn], colorFn, outputFn); const xs = getNodeXs(outputFn); expect(xs.length).toBe(3); @@ -84,7 +84,7 @@ it('Test compose chains multiple transform functions and propagates breakpoints colorFn.addRGBPoint(100, 0, 0, 1); const outputFn = vtkColorTransferFunction.newInstance(); - compose([0, 100], [fn1, fn2], colorFn, outputFn); + compose([fn1, fn2], colorFn, outputFn); const xs = getNodeXs(outputFn); expect(xs.length).toBe(3); @@ -115,12 +115,12 @@ it('Test compose clears previously composed points on subsequent calls', () => { colorFn.addRGBPoint(100, 0, 0, 1); const outputFn = vtkColorTransferFunction.newInstance(); - compose([0, 100], [fn], colorFn, outputFn); + compose([fn], colorFn, outputFn); expect(outputFn.getSize()).toBe(3); colorFn.removeAllPoints(); colorFn.addRGBPoint(0, 1, 1, 1); colorFn.addRGBPoint(100, 0, 0, 0); - compose([0, 100], [fn], colorFn, outputFn); + compose([fn], colorFn, outputFn); expect(outputFn.getSize()).toBe(2); });