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');

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

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;
model.previousPosition = [
...model.manipulator.handleEvent(callData, model.apiSpecificRenderWindow),
];
model.apiSpecificRenderWindow.setCursor('grabbing');
model.interactor.requestAnimation(publicAPI);
}

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

/**
* check for handle 2 position in comparison to handle 1 position
* and sets text offset to not overlap on the line representation
*/

function getOffsetDirectionForTextPosition() {
const pos1 = publicAPI.getHandle(0).getOrigin();
const pos2 = publicAPI.getHandle(1).getOrigin();

let dySign = 1;
if (pos1[0] <= pos2[0]) {
dySign = pos1[1] <= pos2[1] ? 1 : -1;
} else {
dySign = pos1[1] <= pos2[1] ? -1 : 1;
}
return dySign;
}

/**
* place SVGText on line according to both handle positions
* which purpose is to never have text representation overlapping
* on PolyLine representation
* */
publicAPI.placeText = () => {
const dySign = getOffsetDirectionForTextPosition();
const textPropsCp = { ...model.representations[3].getTextProps() };
textPropsCp.dy = dySign * Math.abs(textPropsCp.dy);
model.representations[3].setTextProps(textPropsCp);
model.interactor.render();
};

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 getLineDirection(point1, point2);
}

/**
* Orient handle
* @param {number} handleIndex 0, 1 or 2
* @param {object} callData optional, see getHandleOrientation for details.
*/
function updateHandleOrientation(handleIndex, callData = null) {
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 ---------------------------------------------------------

publicAPI.setMoveHandleVisibility = (visibility) => {
model.representations[2].setVisibilityFlagArray([visibility, visibility]);
model.widgetState.getMoveHandle().setVisible(visibility);
model.representations[2].updateActorVisibility();
};

/**
* 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.placeText();
publicAPI.setMoveHandleVisibility(false);
}
};

// --------------------------------------------------------------------------
// 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.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) => {
if (model.hasFocus && publicAPI.isPlaced() && !model.isDragging) {
publicAPI.loseFocus();
return macro.VOID;
}
if (
model.pickable &&
model.dragable &&
model.manipulator &&
model.activeState &&
model.activeState.getActive() &&
!ignoreKey(callData)
) {
const worldCoords = model.manipulator.handleEvent(
callData,
model.apiSpecificRenderWindow
);
const translation = model.previousPosition
? vtkMath.subtract(worldCoords, model.previousPosition, [])
: [0, 0, 0];
model.previousPosition = worldCoords;
if (
// is placing first or second handle
model.activeState === model.widgetState.getMoveHandle() ||
// is dragging already placed first or second handle
model.isDragging
) {
if (model.activeState.setOrigin) {
model.activeState.setOrigin(worldCoords);
} else {
// Dragging line
publicAPI
.getHandle(0)
.setOrigin(
vtkMath.add(publicAPI.getHandle(0).getOrigin(), translation, [])
);
publicAPI
.getHandle(1)
.setOrigin(
vtkMath.add(publicAPI.getHandle(1).getOrigin(), translation, [])
);
}
publicAPI.updateHandleOrientations();
updateTextPosition(model);
publicAPI.invokeInteractionEvent();
return macro.EVENT_ABORT;
}
}
return macro.VOID;
};

// --------------------------------------------------------------------------
// Left release: Finish drag / Create new handle
// --------------------------------------------------------------------------

publicAPI.handleLeftButtonRelease = () => {
// After dragging a point or placing all points
if (
model.activeState &&
model.activeState.getActive() &&
(model.isDragging || publicAPI.isPlaced())
) {
const wasTextActive = model.widgetState.getText().getActive();
// Recompute offsets
publicAPI.placeText();
model.widgetState.deactivate();
model.widgetState.getMoveHandle().deactivate();
model.activeState = null;

if (!wasTextActive) {
model.interactor.cancelAnimation(publicAPI);
}
model.apiSpecificRenderWindow.setCursor('pointer');

model.hasFocus = false;

publicAPI.invokeEndInteractionEvent();
model.widgetManager.enablePicking();
model.interactor.render();
}

if (
model.isDragging === false &&
(!model.activeState || !model.activeState.getActive())
) {
publicAPI.rotateHandlesToFaceCamera();
}
model.isDragging = false;
};

// --------------------------------------------------------------------------
// 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());
publicAPI.setMoveHandleVisibility(true);
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.activeState = null;
model.hasFocus = false;
model.widgetManager.enablePicking();
model.interactor.render();
};
}
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();
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) {
const handle1Origin = widgetState.getHandle1().getOrigin();
if (handleIndex === 0) {
return handle1Origin.length > 0;
}

const handle2Origin = widgetState.getHandle2().getOrigin();
return (
handle1Origin.length > 0 &&
handle2Origin.length > 0 &&
!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.length ? 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 vtkSVGLandmarkRepresentation from 'vtk.js/Sources/Widgets/SVG/SVGLandmarkRepresentation';
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');
model.widgetState = stateGenerator();
model.behavior = widgetBehavior;

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

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

publicAPI.getRepresentationsForViewType = (viewType) => {
switch (viewType) {
case ViewTypes.DEFAULT:
case ViewTypes.GEOMETRY:
case ViewTypes.SLICE:
case ViewTypes.VOLUME:
default:
return [
{
builder: vtkArrowHandleRepresentation,
labels: ['handle1'],
initialValues: {
/* to scale handle size when zooming/dezooming, optional */
scaleInPixels: true,
/*
* 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: {
/* to scale handle size when zooming/dezooming, optional */
scaleInPixels: true,
/*
* 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: {
scaleInPixels: true,
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: vtkSVGLandmarkRepresentation,
initialValues: {
showCircle: false,
isVisible: false,
text: '',
textProps: {
dx: 12,
dy: -12,
},
},
labels: ['SVGtext'],
},
{
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;
};

// --------------------------------------------------------------------------
// 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);
});

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

// Default manipulator
model.manipulator = vtkPlanePointManipulator.newInstance();
}

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

const DEFAULT_VALUES = {
isDragging: false,
};

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

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

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

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', 'shape'],
name: 'moveHandle',
initialValues: {
scale1: 50,
origin: [],
visible: true,
},
})
.addStateFromMixin({
labels: ['handle1'],
mixins: ['origin', 'color', 'scale1', 'visible', 'manipulator', 'shape'],
name: 'handle1',
initialValues: {
scale1: 50,
origin: [],
},
})
.addStateFromMixin({
labels: ['handle2'],
mixins: ['origin', 'color', 'scale1', 'visible', 'manipulator', 'shape'],
name: 'handle2',
initialValues: {
scale1: 50,
origin: [],
},
})
.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: '',
visible: false,
origin: [0, 0, 0],
},
})
.addStateFromInstance({ name: 'positionOnLine', instance: linePosState })
.addField({ name: 'lineThickness' })
.build();
}