LineWidget

This class represents a line with customizable ends to show points
of interests. It consists of 2 handles linked by a line and a text area
to add annotations on the widget

List of shapes

Shapes may be changed in the state.js file

3D Shapes

3D shapes include :
- Sphere
- Cone
- Cube
- Void handle (no handle on the VTK scene, the user can’t interact with
this end of the line)

2D Shapes

2D Shapes include :
- triangle
- 4 Points arrow head
- 6 points arrow head
- Star
- Disk
- Circle
- ViewFinder

Handle orientation

Shape direction

Shapes meant to represent an arrow will follow the direction of the line. Those shapes include cone, arrow head (4 and 6) and triangle.

Shape rotation

Shape rotation comes in 3 behaviors:
- NONE : will rotate only 2d shapes to face camera
- TRUE : will rotate every shape to face camera
- FALSE: will not rotate shapes
These mods are set in the Constants file and are to be changed directly on the
representation of a handle

Handle visibility

Handles can have 3 states of visibility. First the classic visible handle,
then the non visible handle that can be interacted with on mouseOver - visible feature to toggle in state.js - and the complete invisible invisible
handle with no possibility of user interaction - the voidSphere handle, a feature to activate by changing widget shape to voidSphere in state.js file.

Text utilities

The text is an SVG element. It is meant to be placed along the line.Moving handles will cause text to reposition in order to avoid as much as possible having letters overlapping on the line representation. Position of the text along the line may be changed.

  • setText : sets the SVG Text value to display text
  • positionOnLine: a substate to indicate where the text should be positioned. A scalar which value should be set between 0 and 1, with 0 placing text on handle1 position and 1 on handle2 position.

Source

Constants.js
export const ShapeType = {
// NONE is a sphere handle always invisible even on mouseover, which
// prevents user from moving handle once it is placed
NONE: 'voidSphere',
// 3D handles
SPHERE: 'sphere',
CUBE: 'cube',
CONE: 'cone',
// 2D handles
ARROWHEAD3: 'triangle',
ARROWHEAD4: '4pointsArrowHead',
ARROWHEAD6: '6pointsArrowHead',
STAR: 'star',
DISK: 'disk',
CIRCLE: 'circle',
VIEWFINDER: 'viewFinder',
};

export const Shapes2D = [
ShapeType.ARROWHEAD3,
ShapeType.ARROWHEAD4,
ShapeType.ARROWHEAD6,
ShapeType.STAR,
ShapeType.DISK,
ShapeType.CIRCLE,
ShapeType.VIEWFINDER,
];
export const Shapes3D = [ShapeType.SPHERE, ShapeType.CUBE, ShapeType.CONE];

export const ShapesOrientable = [
ShapeType.CONE,
ShapeType.ARROWHEAD3,
ShapeType.ARROWHEAD4,
ShapeType.ARROWHEAD6,
];

export default {
ShapeType,
Shapes2D,
Shapes3D,
ShapesOrientable,
};
behavior.js
import Constants from 'vtk.js/Sources/Widgets/Widgets3D/LineWidget/Constants';
import macro from 'vtk.js/Sources/macros';
import * as vtkMath from 'vtk.js/Sources/Common/Core/Math';
import {
calculateTextPosition,
updateTextPosition,
getNumberOfPlacedHandles,
isHandlePlaced,
getPoint,
} from 'vtk.js/Sources/Widgets/Widgets3D/LineWidget/helpers';

const { ShapeType } = Constants;
// Total number of points to place
const MAX_POINTS = 2;

const handleGetters = ['getHandle1', 'getHandle2', 'getMoveHandle'];

export default function widgetBehavior(publicAPI, model) {
model.classHierarchy.push('vtkLineWidgetProp');
model._isDragging = false;

/**
* Returns the handle at the handleIndex'th index.
* @param {number} handleIndex 0, 1 or 2
*/
publicAPI.getHandle = (handleIndex) =>
model.widgetState[handleGetters[handleIndex]]();

/**
* Return the index in the of tbe handle in `representations` array,
* or -1 if the handle is not present in the widget state.
*/
publicAPI.getHandleIndex = (handle) => {
switch (handle) {
case model.widgetState.getHandle1():
return 0;
case model.widgetState.getHandle2():
return 1;
case model.widgetState.getMoveHandle():
return 2;
default:
return -1;
}
};

publicAPI.isPlaced = () =>
getNumberOfPlacedHandles(model.widgetState) === MAX_POINTS;

// --------------------------------------------------------------------------
// Interactor event
// --------------------------------------------------------------------------

function ignoreKey(e) {
return e.altKey || e.controlKey || e.shiftKey;
}

function updateCursor(callData) {
model._isDragging = true;
const manipulator =
model.activeState?.getManipulator?.() ?? model.manipulator;
manipulator.handleEvent(callData, model._apiSpecificRenderWindow);
model._apiSpecificRenderWindow.setCursor('grabbing');
model._interactor.requestAnimation(publicAPI);
}

// --------------------------------------------------------------------------
// Text methods
// --------------------------------------------------------------------------

publicAPI.setText = (text) => {
model.widgetState.getText().setText(text);
model._interactor.render();
};

// --------------------------------------------------------------------------
// Handle positioning methods
// --------------------------------------------------------------------------

// Handle utilities ---------------------------------------------------------

function getLineDirection(p1, p2) {
const dir = vtkMath.subtract(p1, p2, []);
vtkMath.normalize(dir);
return dir;
}

// Handle orientation & rotation ---------------------------------------------------------

function computeMousePosition(p1, callData) {
const displayMousePos = publicAPI.computeWorldToDisplay(
model._renderer,
...p1
);
const worldMousePos = publicAPI.computeDisplayToWorld(
model._renderer,
callData.position.x,
callData.position.y,
displayMousePos[2]
);
return worldMousePos;
}

/**
* Returns the handle orientation to match the direction vector of the polyLine from one tip to another
* @param {number} handleIndex 0 for handle1, 1 for handle2
* @param {object} callData if specified, uses mouse position as 2nd point
*/
function getHandleOrientation(handleIndex, callData = null) {
const point1 = getPoint(handleIndex, model.widgetState);
const point2 = callData
? computeMousePosition(point1, callData)
: getPoint(1 - handleIndex, model.widgetState);
return point1 && point2 ? getLineDirection(point1, point2) : null;
}

/**
* Orient handle
* @param {number} handleIndex 0, 1 or 2
* @param {object} callData optional, see getHandleOrientation for details.
*/
function updateHandleOrientation(handleIndex) {
const orientation = getHandleOrientation(Math.min(1, handleIndex));
model.representations[handleIndex].setOrientation(orientation);
}

publicAPI.updateHandleOrientations = () => {
updateHandleOrientation(0);
updateHandleOrientation(1);
updateHandleOrientation(2);
};

publicAPI.rotateHandlesToFaceCamera = () => {
model.representations[0].setViewMatrix(
Array.from(model._camera.getViewMatrix())
);
model.representations[1].setViewMatrix(
Array.from(model._camera.getViewMatrix())
);
};

// Handles visibility ---------------------------------------------------------

/**
* Set actor visibility to true unless it is a NONE handle
* and uses state visibility variable for the displayActor visibility to
* allow pickable handles even when they are not displayed on screen
* @param handle : the handle state object
* @param handleNb : the handle number according to its label in widget state
*/
publicAPI.updateHandleVisibility = (handleIndex) => {
const handle = publicAPI.getHandle(handleIndex);
const visibility =
handle.getVisible() && isHandlePlaced(handleIndex, model.widgetState);
model.representations[handleIndex].setVisibilityFlagArray([
visibility,
visibility && handle.getShape() !== ShapeType.NONE,
]);
model.representations[handleIndex].updateActorVisibility();
model._interactor.render();
};

/**
* Called when placing a point from the first time.
* @param {number} handleIndex
*/
publicAPI.placeHandle = (handleIndex) => {
const handle = publicAPI.getHandle(handleIndex);
handle.setOrigin(...model.widgetState.getMoveHandle().getOrigin());

publicAPI.updateHandleOrientations();
publicAPI.rotateHandlesToFaceCamera();
model.widgetState.getText().setOrigin(calculateTextPosition(model));
publicAPI.updateHandleVisibility(handleIndex);

if (handleIndex === 0) {
// For the line (handle1, handle2, moveHandle) to be displayed
// correctly, handle2 origin must be valid.
publicAPI
.getHandle(1)
.setOrigin(...model.widgetState.getMoveHandle().getOrigin());
// Now that handle2 has a valid origin, hide it
publicAPI.updateHandleVisibility(1);

model.widgetState
.getMoveHandle()
.setShape(publicAPI.getHandle(1).getShape());
}
if (handleIndex === 1) {
publicAPI.loseFocus();
}
};

// --------------------------------------------------------------------------
// Left press: Select handle to drag
// --------------------------------------------------------------------------

publicAPI.handleLeftButtonPress = (e) => {
if (
!model.activeState ||
!model.activeState.getActive() ||
!model.pickable ||
ignoreKey(e)
) {
return macro.VOID;
}
if (
model.activeState === model.widgetState.getMoveHandle() &&
getNumberOfPlacedHandles(model.widgetState) === 0
) {
publicAPI.placeHandle(0);
} else if (
model.widgetState.getMoveHandle().getActive() &&
getNumberOfPlacedHandles(model.widgetState) === 1
) {
publicAPI.placeHandle(1);
} else if (model.dragable && !model.widgetState.getText().getActive()) {
// Grab handle1, handle2 or whole widget
updateCursor(e);
}
publicAPI.invokeStartInteractionEvent();
return macro.EVENT_ABORT;
};

// --------------------------------------------------------------------------
// Mouse move: Drag selected handle / Handle follow the mouse
// --------------------------------------------------------------------------

publicAPI.handleMouseMove = (callData) => {
const manipulator =
model.activeState?.getManipulator?.() ?? model.manipulator;
if (
manipulator &&
model.pickable &&
model.dragable &&
model.activeState &&
model.activeState.getActive() &&
!ignoreKey(callData)
) {
const { worldCoords, worldDelta } = manipulator.handleEvent(
callData,
model._apiSpecificRenderWindow
);

const isHandleMoving =
// is placing first or second handle
model.activeState === model.widgetState.getMoveHandle() ||
// is dragging already placed first or second handle
model._isDragging;

// the line state doesn't have setOrigin
const isDraggingLine = !model.activeState.setOrigin;

if (isHandleMoving) {
if (!isDraggingLine) {
const curOrigin = model.activeState.getOrigin();
if (curOrigin) {
model.activeState.setOrigin(
vtkMath.add(model.activeState.getOrigin(), worldDelta, [])
);
} else {
model.activeState.setOrigin(worldCoords);
}
publicAPI.updateHandleVisibility(
publicAPI.getHandleIndex(model.activeState)
);
} else {
// Dragging line; move all handles
for (let i = 0; i < 2; i++) {
const handleOrigin = publicAPI.getHandle(i).getOrigin();
publicAPI
.getHandle(i)
.setOrigin(vtkMath.add(handleOrigin, worldDelta, []));
}
}
publicAPI.updateHandleOrientations();
updateTextPosition(model);
publicAPI.invokeInteractionEvent();
return macro.EVENT_ABORT;
}
}
return macro.VOID;
};

// --------------------------------------------------------------------------
// Left release: Finish drag
// --------------------------------------------------------------------------

publicAPI.handleLeftButtonRelease = () => {
if (
!model.activeState ||
!model.activeState.getActive() ||
!model.pickable
) {
publicAPI.rotateHandlesToFaceCamera();
return macro.VOID;
}
if (model.hasFocus && publicAPI.isPlaced()) {
publicAPI.loseFocus();
return macro.VOID;
}

if (model._isDragging && publicAPI.isPlaced()) {
const wasTextActive = model.widgetState.getText().getActive();
// Recompute offsets
model.widgetState.deactivate();
model.activeState = null;
if (!wasTextActive) {
model._interactor.cancelAnimation(publicAPI);
}
model._apiSpecificRenderWindow.setCursor('pointer');

model.hasFocus = false;
model._isDragging = false;
} else if (model.activeState !== model.widgetState.getMoveHandle()) {
model.widgetState.deactivate();
}

if (
(model.hasFocus && !model.activeState) ||
(model.activeState && !model.activeState.getActive())
) {
model._widgetManager.enablePicking();
model._interactor.render();
}

publicAPI.invokeEndInteractionEvent();
return macro.EVENT_ABORT;
};

// --------------------------------------------------------------------------
// Focus API - moveHandle follow mouse when widget has focus
// --------------------------------------------------------------------------

publicAPI.grabFocus = () => {
if (!model.hasFocus && !publicAPI.isPlaced()) {
model.activeState = model.widgetState.getMoveHandle();
model.activeState.setShape(publicAPI.getHandle(0).getShape());
model.activeState.activate();
model._interactor.requestAnimation(publicAPI);
publicAPI.invokeStartInteractionEvent();
}
model.hasFocus = true;
};

// --------------------------------------------------------------------------

publicAPI.loseFocus = () => {
if (model.hasFocus) {
model._interactor.cancelAnimation(publicAPI);
publicAPI.invokeEndInteractionEvent();
}
model.widgetState.deactivate();
model.widgetState.getMoveHandle().deactivate();
model.widgetState.getMoveHandle().setOrigin(null);
model.activeState = null;
model.hasFocus = false;
model._widgetManager.enablePicking();
model._interactor.render();
};

publicAPI.reset = () => {
model.widgetState.deactivate();
model.widgetState.getMoveHandle().deactivate();

model.widgetState.getHandle1().setOrigin(null);
model.widgetState.getHandle2().setOrigin(null);
model.widgetState.getMoveHandle().setOrigin(null);
model.widgetState.getText().setOrigin(null);
model.widgetState.getText().setText('');

model.activeState = null;
};
}
helpers.js
import * as vtkMath from 'vtk.js/Sources/Common/Core/Math/';

export function calculateTextPosition(model) {
const vector = [0, 0, 0];
const handle1WorldPos = model.widgetState.getHandle1().getOrigin();
const handle2WorldPos = model.widgetState.getHandle2().getOrigin();
if (!handle1WorldPos || !handle2WorldPos) {
return null;
}
let statePositionOnLine = model.widgetState
.getPositionOnLine()
.getPosOnLine();
statePositionOnLine = 1 - statePositionOnLine;
vtkMath.subtract(handle1WorldPos, handle2WorldPos, vector);
vtkMath.multiplyScalar(vector, statePositionOnLine);
vtkMath.add(vector, handle2WorldPos, vector);
return vector;
}

export function updateTextPosition(model) {
const SVGTextState = model.widgetState.getText();
SVGTextState.setOrigin(calculateTextPosition(model));
}

export function isHandlePlaced(handleIndex, widgetState) {
if (handleIndex === 2) {
return widgetState.getMoveHandle().getOrigin() != null;
}

const handle1Origin = widgetState.getHandle1().getOrigin();
if (handleIndex === 0) {
return handle1Origin != null;
}

const handle2Origin = widgetState.getHandle2().getOrigin();
return (
handle1Origin &&
handle2Origin &&
!vtkMath.areEquals(handle1Origin, handle2Origin, 0)
);
}

/**
* Returns the world position of line extremities (placed or not).
* Returns null if no extremity exist.
* @param {number} handleIndex 0 or 1
* @param {object} widgetState state of line widget
* @param {bool} moveHandle Get move handle position if moveHandle is true and handle is not placed
*/
export function getPoint(handleIndex, widgetState, moveHandle = true) {
const handle =
moveHandle && !isHandlePlaced(handleIndex, widgetState)
? widgetState.getMoveHandle()
: widgetState[`getHandle${handleIndex + 1}`]();
const origin = handle.getOrigin();
return origin || null;
}

/**
* Returns the number of handle placed on the scene by checking
* handle positions. Returns 2 when the user is still
* placing 2nd handle
* */
export function getNumberOfPlacedHandles(widgetState) {
let numberOfPlacedHandles = 0;
if (isHandlePlaced(0, widgetState)) {
numberOfPlacedHandles = 1 + isHandlePlaced(1, widgetState);
}
return numberOfPlacedHandles;
}
index.js
import { distance2BetweenPoints } from 'vtk.js/Sources/Common/Core/Math';
import macro from 'vtk.js/Sources/macros';
import stateGenerator from 'vtk.js/Sources/Widgets/Widgets3D/LineWidget/state';
import vtkAbstractWidgetFactory from 'vtk.js/Sources/Widgets/Core/AbstractWidgetFactory';
import vtkArrowHandleRepresentation from 'vtk.js/Sources/Widgets/Representations/ArrowHandleRepresentation';
import vtkPlanePointManipulator from 'vtk.js/Sources/Widgets/Manipulators/PlaneManipulator';
import vtkPolyLineRepresentation from 'vtk.js/Sources/Widgets/Representations/PolyLineRepresentation';
import widgetBehavior from 'vtk.js/Sources/Widgets/Widgets3D/LineWidget/behavior';
import { Behavior } from 'vtk.js/Sources/Widgets/Representations/WidgetRepresentation/Constants';
import { ViewTypes } from 'vtk.js/Sources/Widgets/Core/WidgetManager/Constants';
import {
getPoint,
updateTextPosition,
} from 'vtk.js/Sources/Widgets/Widgets3D/LineWidget/helpers';
// ----------------------------------------------------------------------------
// Factory
// ----------------------------------------------------------------------------

function vtkLineWidget(publicAPI, model) {
model.classHierarchy.push('vtkLineWidget');

const superClass = { ...publicAPI };

// --- Widget Requirement ---------------------------------------------------

model.methodsToLink = [
'activeScaleFactor',
'activeColor',
'useActiveColor',
'glyphResolution',
'defaultScale',
'scaleInPixels',
];

publicAPI.getRepresentationsForViewType = (viewType) => {
switch (viewType) {
case ViewTypes.DEFAULT:
case ViewTypes.GEOMETRY:
case ViewTypes.SLICE:
case ViewTypes.VOLUME:
default:
return [
{
builder: vtkArrowHandleRepresentation,
labels: ['handle1'],
initialValues: {
/*
* This table sets the visibility of the handles' actors
* 1st actor is a displayActor, which hides a rendered object on the HTML layer.
* operating on its value allows to hide a handle to the user while still being
* able to detect its presence, so the user can move it. 2nd actor is a classic VTK
* actor which renders the object on the VTK scene
*/
visibilityFlagArray: [false, false],
coincidentTopologyParameters: {
Point: {
factor: -1.0,
offset: -1.0,
},
Line: {
factor: -1.0,
offset: -1.0,
},
Polygon: {
factor: -3.0,
offset: -3.0,
},
},
},
},
{
builder: vtkArrowHandleRepresentation,
labels: ['handle2'],
initialValues: {
/*
* This table sets the visibility of the handles' actors
* 1st actor is a displayActor, which hides a rendered object on the HTML layer.
* operating on its value allows to hide a handle to the user while still being
* able to detect its presence, so the user can move it. 2nd actor is a classic VTK
* actor which renders the object on the VTK scene
*/
visibilityFlagArray: [false, false],
coincidentTopologyParameters: {
Point: {
factor: -1.0,
offset: -1.0,
},
Line: {
factor: -1.0,
offset: -1.0,
},
Polygon: {
factor: -3.0,
offset: -3.0,
},
},
},
},
{
builder: vtkArrowHandleRepresentation,
labels: ['moveHandle'],
initialValues: {
visibilityFlagArray: [false, false],
coincidentTopologyParameters: {
Point: {
factor: -1.0,
offset: -1.0,
},
Line: {
factor: -1.0,
offset: -1.0,
},
Polygon: {
factor: -3.0,
offset: -3.0,
},
},
},
},
{
builder: vtkPolyLineRepresentation,
labels: ['handle1', 'handle2', 'moveHandle'],
initialValues: {
behavior: Behavior.HANDLE,
pickable: true,
},
},
];
}
};

// --- Public methods -------------------------------------------------------

publicAPI.getDistance = () => {
const p1 = getPoint(0, model.widgetState);
const p2 = getPoint(1, model.widgetState);
return p1 && p2 ? Math.sqrt(distance2BetweenPoints(p1, p2)) : 0;
};

publicAPI.setManipulator = (manipulator) => {
superClass.setManipulator(manipulator);
model.widgetState.getMoveHandle().setManipulator(manipulator);
model.widgetState.getHandle1().setManipulator(manipulator);
model.widgetState.getHandle2().setManipulator(manipulator);
};

// --------------------------------------------------------------------------
// initialization
// --------------------------------------------------------------------------

/**
* TBD: Why setting the move handle ?
*/
model.widgetState.onBoundsChange((bounds) => {
const center = [
(bounds[0] + bounds[1]) * 0.5,
(bounds[2] + bounds[3]) * 0.5,
(bounds[4] + bounds[5]) * 0.5,
];
model.widgetState.getMoveHandle().setOrigin(center);
});

let linePosSub = model.widgetState.getPositionOnLine().onModified(() => {
updateTextPosition(model);
});

// Default manipulator
publicAPI.setManipulator(
model.manipulator ||
vtkPlanePointManipulator.newInstance({ useCameraNormal: true })
);

publicAPI.delete = macro.chain(publicAPI.delete, () => {
linePosSub.unsubscribe();
linePosSub = null;
});
}

// ----------------------------------------------------------------------------

const defaultValues = (initialValues) => ({
// manipulator: null,
behavior: widgetBehavior,
widgetState: stateGenerator(),
...initialValues,
});

// ----------------------------------------------------------------------------

export function extend(publicAPI, model, initialValues = {}) {
Object.assign(model, defaultValues(initialValues));

vtkAbstractWidgetFactory.extend(publicAPI, model, initialValues);
macro.setGet(publicAPI, model, ['manipulator']);

vtkLineWidget(publicAPI, model);
}

// ----------------------------------------------------------------------------

export const newInstance = macro.newInstance(extend, 'vtkLineWidget');

// ----------------------------------------------------------------------------

export default { newInstance, extend };
state.js
import vtkStateBuilder from 'vtk.js/Sources/Widgets/Core/StateBuilder';

// make line position a sub-state so we can listen to it
// separately from the rest of the widget state.

const linePosState = vtkStateBuilder
.createBuilder()
.addField({
name: 'posOnLine',
initialValue: 0.5,
})
.build();

export default function generateState() {
return vtkStateBuilder
.createBuilder()
.addStateFromMixin({
labels: ['moveHandle'],
mixins: ['origin', 'color', 'scale1', 'visible', 'manipulator', 'shape'],
name: 'moveHandle',
initialValues: {
scale1: 30,
visible: true,
},
})
.addStateFromMixin({
labels: ['handle1'],
mixins: ['origin', 'color', 'scale1', 'visible', 'manipulator', 'shape'],
name: 'handle1',
initialValues: {
scale1: 30,
},
})
.addStateFromMixin({
labels: ['handle2'],
mixins: ['origin', 'color', 'scale1', 'visible', 'manipulator', 'shape'],
name: 'handle2',
initialValues: {
scale1: 30,
},
})
.addStateFromMixin({
labels: ['SVGtext'],
mixins: ['origin', 'color', 'text', 'visible'],
name: 'text',
initialValues: {
/* text is empty to set a text filed in the SVGLayer and to avoid
* displaying text before positioning the handles */
text: '',
},
})
.addStateFromInstance({ name: 'positionOnLine', instance: linePosState })
.addField({ name: 'lineThickness' })
.build();
}