@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
1,084 lines (1,040 loc) • 34.9 kB
JavaScript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import { Box3, BufferGeometry, Color, Float32BufferAttribute, Group, Line3, LineBasicMaterial, LineSegments, MathUtils, Sphere, Vector2, Vector3 } from 'three';
import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
import { getGeometryMemoryUsage } from '../core/MemoryUsage';
import Helpers from '../helpers/Helpers';
import { isBufferGeometry, isCSS2DObject } from '../utils/predicates';
import { nonNull } from '../utils/tsutils';
import Entity3D from './Entity3D';
const mod = MathUtils.euclideanModulo;
const UP = new Vector2(0, 1);
const RIGHT = new Vector2(1, 0);
const tmpVec2 = new Vector2();
const tmpVec3 = new Vector3();
const tmpBl = new Vector2();
const tmpBr = new Vector2();
const tmpTl = new Vector2();
const tmpTr = new Vector2();
const tmp = {
position: new Vector3(),
planeNormal: new Vector3(),
edgeCenter: new Vector3(),
sideCenter: new Vector3(),
v2: new Vector2(),
sphere: new Sphere()
};
/**
* The grid step values.
*/
/**
* The grid volume.
*/
/**
* The grid formatting options.
*/
export const DEFAULT_STYLE = {
color: new Color('white'),
fontSize: 10,
numberFormat: new Intl.NumberFormat()
};
/**
* Describes the starting point of the ticks.
*/
export let TickOrigin = /*#__PURE__*/function (TickOrigin) {
/**
* Tick values represent distances to the grid's lower left corner
*/
TickOrigin[TickOrigin["Relative"] = 0] = "Relative";
/**
* Tick values represent coordinates in the CRS of the scene.
*/
TickOrigin[TickOrigin["Absolute"] = 1] = "Absolute";
return TickOrigin;
}({});
/**
* Returns the padding to apply to a label that is located at the edge of the viewport,
* according to its normalized device coordinates (NDC), to ensure that the label is fully
* visible and not partially outside of the viewport.
*/
function getPaddingForAdaptiveLabel(ndc, fontSize, text) {
const {
x,
y
} = ndc;
const yMargin = fontSize * 2;
const xMargin = fontSize * 0.7; // per character
// top right bottom left
const top = y > 0.95 ? yMargin : 0;
const bottom = y < -0.95 ? yMargin : 0;
const charCount = text.length;
const right = x > 0.95 ? xMargin * charCount : 0;
const left = x < -0.95 ? xMargin * charCount : 0;
return `${top}pt ${right}pt ${bottom}pt ${left}pt`;
}
class Side extends LineSegments {
logicalVisibility = false;
constructor(geometry, material, lines) {
super(geometry, material);
this.lines = lines;
}
}
class Edge extends Group {
isEdge = true;
constructor(side1, side2) {
super();
this.side1 = side1;
this.side2 = side2;
}
}
function getCssColor(color) {
return `#${new Color(color).getHexString()}`;
}
function createLabelElement(text, color, opacity, fontSize) {
const container = document.createElement('div');
// Static properties
container.style.textAlign = 'center';
// Dynamic properties
const label = document.createElement('span');
label.innerText = text;
label.style.paddingLeft = '5pt';
label.style.paddingRight = '5pt';
container.appendChild(label);
// API exposed properties
container.style.opacity = `${opacity}`;
container.style.color = color;
container.style.fontSize = `${fontSize}pt`;
return {
container,
label
};
}
/**
* Constructor options for the {@link AxisGrid} entity.
*/
/**
* Create a 3D axis grid. This is represented as a box volume where each side of the box is itself a
* grid.
*
* ```js
* // Create a 200x200 meters extent
* const extent = new Extent(CoordinateSystem.epsg3857, -100, +100, -100, +100);
*
* // Create an AxisGrid on this extent, with the grid floor at zero meters,
* // and the grid ceiling at 2500 meters.
* //
* // Display a tick (grid line), every 10 meters on the horizontal axes,
* // and every 50 meters on the vertical axis.
* const grid = new AxisGrid({
* volume: {
* extent,
* floor: 0,
* ceiling: 2500,
* },
* origin: TickOrigin.Relative,
* ticks: {
* x: 10,
* y: 10,
* z: 50,
* },
* });
* ```
*
* ## Label customization
*
* By registering the `'label-created'` event, you can modify the DOM element for the newly created label:
*
* ```js
* grid.addEventListener('label-created', ({ label }) => label.classList.add('my-custom-css-class'));
* ```
*/
class AxisGrid extends Entity3D {
type = 'AxisGrid';
/**
* Read-only flag to check if a given object is of type AxisGrid.
*/
isAxisGrid = true;
_unitSuffix = '';
_showLabels = true;
_adaptiveLabels = false;
_disposed = false;
_lastCamera = null;
_boundingBox = null;
_dimensions = null;
_arrowRoot = null;
_floor = null;
_ceiling = null;
_front = null;
_back = null;
_left = null;
_right = null;
_height = null;
_midHeight = null;
_needsRebuild = false;
/**
* Creates an instance of AxisGrid.
*
* @param options - The options.
*/
constructor(options) {
super(options);
this._root = this.object3d;
this._edgeLabelRoot = new Group();
this._edgeLabelRoot.name = 'edge labels';
this._adaptiveLabelRoot = new Group();
this._adaptiveLabelRoot.name = 'adaptive labels';
this._style = {
color: options.style?.color ?? DEFAULT_STYLE.color,
fontSize: options.style?.fontSize ?? DEFAULT_STYLE.fontSize,
numberFormat: options.style?.numberFormat ?? DEFAULT_STYLE.numberFormat
};
this._adaptiveLabels = options.adaptiveLabels ?? this._adaptiveLabels;
this.onObjectCreated(this._edgeLabelRoot);
this.onObjectCreated(this._adaptiveLabelRoot);
this._root.add(this._edgeLabelRoot);
this._root.add(this._adaptiveLabelRoot);
this._boundingSphere = new Sphere();
this._boundingBoxCenter = new Vector3();
if (options.volume == null) {
throw new Error('options.volume is undefined');
}
this._volume = options.volume;
this._ticks = options.ticks ?? {
x: 100,
y: 100,
z: 100
};
this._origin = options.origin ?? TickOrigin.Relative;
const crs = this.volume.extent.crs;
const unit = crs.horizontal?.unit;
if (unit != null) {
// TODO we should distinguish between horizontal and vertical units ideally.
this._unitSuffix = unit.getSymbol();
}
const color = new Color(this.style.color);
this._material = new LineBasicMaterial({
color
});
this._cameraForward = new Vector3();
this._showFloorGrid = true;
this._showCeilingGrid = true;
this._showSideGrids = true;
this.showHelpers = false;
this.refresh();
}
getMemoryUsage(context) {
this.traverse(obj => {
if ('geometry' in obj && isBufferGeometry(obj.geometry)) {
getGeometryMemoryUsage(context, obj.geometry);
}
});
}
updateOpacity() {
const v = this.opacity;
this.forEachLabel(label => label.element.style.opacity = `${v}`);
const mat = this._material;
mat.opacity = v;
mat.transparent = v < 1.0;
mat.needsUpdate = true;
}
/**
* Gets or sets the style.
* You will need to call {@link refresh} to recreate the grid.
*/
get style() {
return this._style;
}
set style(v) {
if (v === undefined || v === null) {
throw new Error('cannot assign undefined/null style');
}
this._style = v;
}
/**
* Gets or sets the volume.
* You will need to call {@link refresh} to recreate the grid.
*/
get volume() {
return this._volume;
}
set volume(v) {
if (v === undefined || v === null) {
throw new Error('cannot assign undefined/null volume');
}
this._volume = v;
}
/**
* Gets or sets the tick origin.
* You will need to call {@link refresh} to recreate the grid.
*/
get origin() {
return this._origin;
}
set origin(v) {
if (v === undefined || v === null) {
throw new Error('cannot assign undefined/null origin');
}
this._origin = v;
}
/**
* Gets or sets the grid and label color.
*/
get color() {
return this.style.color;
}
set color(color) {
this._material.color = new Color(color);
this.style.color = color;
this.refresh();
}
/**
* Shows or hides labels.
*/
get showLabels() {
return this._showLabels;
}
set showLabels(v) {
if (v !== this._showLabels) {
this._showLabels = v;
this._edgeLabelRoot.visible = v;
this._adaptiveLabelRoot.visible = v;
this.updateLabelsVisibility(this._lastCamera);
}
}
/**
* Toggles adaptive labels. Adaptive labels are labels that are displayed
* at the intersection of their line and the viewport's edges, so that
* they remain visible even when the grid sides are out of view.
*/
get adaptiveLabels() {
return this._adaptiveLabels;
}
set adaptiveLabels(v) {
if (v !== this._adaptiveLabels) {
this._adaptiveLabels = v;
if (!v) {
this.removeAdaptiveLabels();
}
this.notifyChange(this);
}
}
/**
* Shows or hides the floor grid.
*/
get showFloorGrid() {
return this._showFloorGrid;
}
set showFloorGrid(v) {
if (v !== this._showFloorGrid) {
this._showFloorGrid = v;
this.updateVisibility();
}
}
/**
* Shows or hides the ceiling grid.
*/
get showCeilingGrid() {
return this._showCeilingGrid;
}
set showCeilingGrid(v) {
if (v !== this._showCeilingGrid) {
this._showCeilingGrid = v;
this.updateVisibility();
}
}
/**
* Shows or hides the side grids.
*/
get showSideGrids() {
return this._showSideGrids;
}
set showSideGrids(v) {
if (v !== this._showSideGrids) {
this._showSideGrids = v;
this.updateVisibility();
}
}
/**
* Gets or sets the tick intervals.
* You will need to call {@link refresh} to recreate the grid.
*/
get ticks() {
return this._ticks;
}
set ticks(v) {
if (v === undefined || v === null) {
throw new Error('cannot assign undefined/null ticks');
}
this._ticks = v;
}
forEachLabel(callback) {
this._edgeLabelRoot.traverse(obj => {
if (isCSS2DObject(obj)) {
callback(obj);
}
});
this._adaptiveLabelRoot.traverse(obj => {
if (isCSS2DObject(obj)) {
callback(obj);
}
});
}
/**
* Rebuilds the grid. This is necessary after changing the ticks, volume or origin.
*/
refresh() {
this._needsRebuild = true;
}
rebuildObjects() {
this.volume.extent.centerAsVector2(tmpVec2);
this._root.position.setX(tmpVec2.x);
this._root.position.setY(tmpVec2.y);
this.buildSides();
this.buildEdgeLabels();
this._root.updateMatrixWorld();
this._boundingBox = this.volume.extent.toBox3(this.volume.floor, this.volume.ceiling);
this._boundingBox.getBoundingSphere(this._boundingSphere);
this._boundingBox.getCenter(this._boundingBoxCenter);
this.updateVisibility();
}
removeEdgeLabels() {
this._edgeLabelRoot.traverse(obj => {
if (isCSS2DObject(obj)) {
obj.element.remove();
}
});
this._edgeLabelRoot.clear();
}
removeAdaptiveLabels() {
this._adaptiveLabelRoot.traverse(obj => {
if (isCSS2DObject(obj)) {
obj.element.remove();
}
});
this._adaptiveLabelRoot.clear();
}
updateVisibility() {
super.updateVisibility();
this.updateLabelsVisibility(this._lastCamera);
}
createLabelObject(x, y, z, text, cssColor, opacity, fontSize) {
const {
container,
label
} = createLabelElement(text, cssColor, opacity, fontSize);
this.dispatchEvent({
type: 'label-created',
label
});
const labelObject = new CSS2DObject(container);
labelObject.name = text;
labelObject.position.set(x, y, z);
return labelObject;
}
buildEdgeLabels() {
// Labels are displayed along each edge of the box volume.
// There are 12 edges in a box, and those edges are linked to their two sides.
const labelRoot = this._edgeLabelRoot;
this.removeEdgeLabels();
const numberFormat = this.style.numberFormat;
const cssColor = getCssColor(this.style.color);
const opacity = this.opacity;
const fontSize = this.style.fontSize;
const v = new Vector3();
this.volume.extent.centerAsVector2(tmpVec2);
const origin = tmpVec3;
tmpVec3.set(tmpVec2.x, tmpVec2.y, 0);
/**
* @param side1 - The first shared side of this edge.
* @param side2 - The second shared side of this edge.
* @param start - The position, in world space, of the start of the edge.
* @param end - The position, in world space, of the end of the edge.
* @param startValue - The numerical value of the starting point.
* @param prefix - The prefix to apply to the label text.
* @param suffix - The suffix to apply to the label text.
* @param tick - The distance between each tick.
*/
const createLabelsAlongEdge = (side1, side2, start, end, startValue, prefix, suffix, tick) => {
const g = new Edge(side1, side2);
g.name = `${side1.name}-${side2.name}`;
const edgeCenter = v.lerpVectors(start, end, 0.5).clone();
edgeCenter.sub(origin);
g.position.copy(edgeCenter);
const sideLength = start.distanceTo(end);
let labelDistance = 0;
let t = (tick - mod(startValue + tick, tick)) / sideLength;
// Distribute the labels along the edge, on each tick
do {
v.lerpVectors(start, end, t);
labelDistance = v.distanceTo(start);
const rawValue = startValue + labelDistance;
const labelValue = numberFormat.format(Math.round(rawValue));
const label = this.createLabelObject(v.x - edgeCenter.x - origin.x, v.y - edgeCenter.y - origin.y, v.z - edgeCenter.z - origin.z, `${prefix}${labelValue}${suffix}`, cssColor, opacity, fontSize);
g.add(label);
t += tick / sideLength;
} while (t <= 1);
this.onObjectCreated(g);
labelRoot.add(g);
};
const e = this.volume.extent;
const zmax = this.volume.ceiling;
const zmin = this.volume.floor;
const br = e.bottomRight().toVector2(tmpBr);
const tr = e.topRight().toVector2(tmpTr);
const bl = e.bottomLeft().toVector2(tmpBl);
const tl = e.topLeft().toVector2(tmpTl);
const tlFloor = new Vector3(tl.x, tl.y, zmin);
const trFloor = new Vector3(tr.x, tr.y, zmin);
const brFloor = new Vector3(br.x, br.y, zmin);
const blFloor = new Vector3(bl.x, bl.y, zmin);
const tlCeil = new Vector3(tl.x, tl.y, zmax);
const trCeil = new Vector3(tr.x, tr.y, zmax);
const brCeil = new Vector3(br.x, br.y, zmax);
const blCeil = new Vector3(bl.x, bl.y, zmax);
const floor = nonNull(this._floor);
const ceil = nonNull(this._ceiling);
const front = nonNull(this._front);
const back = nonNull(this._back);
const left = nonNull(this._left);
const right = nonNull(this._right);
const relative = this.origin === TickOrigin.Relative;
const bry = relative ? 0 : br.y;
const blx = relative ? 0 : bl.x;
const tlx = relative ? 0 : tl.x;
const yPrefix = relative ? '' : 'y: ';
const xPrefix = relative ? '' : 'x: ';
const zPrefix = '';
const hSuffix = relative ? this._unitSuffix : '';
const vSuffix = this._unitSuffix;
// floor edges
createLabelsAlongEdge(floor, right, brFloor, trFloor, bry, yPrefix, hSuffix, this._ticks.y);
createLabelsAlongEdge(floor, left, blFloor, tlFloor, bry, yPrefix, hSuffix, this._ticks.y);
createLabelsAlongEdge(floor, front, blFloor, brFloor, blx, xPrefix, hSuffix, this._ticks.x);
createLabelsAlongEdge(floor, back, tlFloor, trFloor, tlx, xPrefix, hSuffix, this._ticks.x);
// ceiling edges
createLabelsAlongEdge(ceil, right, brCeil, trCeil, bry, yPrefix, hSuffix, this._ticks.y);
createLabelsAlongEdge(ceil, left, blCeil, tlCeil, bry, yPrefix, hSuffix, this._ticks.y);
createLabelsAlongEdge(ceil, front, blCeil, brCeil, blx, xPrefix, hSuffix, this._ticks.x);
createLabelsAlongEdge(ceil, back, tlCeil, trCeil, tlx, xPrefix, hSuffix, this._ticks.x);
// vertical (elevation) edges
createLabelsAlongEdge(front, right, brFloor, brCeil, zmin, zPrefix, vSuffix, this._ticks.z);
createLabelsAlongEdge(front, left, blFloor, blCeil, zmin, zPrefix, vSuffix, this._ticks.z);
createLabelsAlongEdge(back, left, tlFloor, tlCeil, zmin, zPrefix, vSuffix, this._ticks.z);
createLabelsAlongEdge(back, right, trFloor, trCeil, zmin, zPrefix, vSuffix, this._ticks.z);
}
/**
* Build adaptive labels: labels that are located at the intersections
* of lines and the viewport edges. They are adaptive because their
* position depends on the camera.
* Note: if no line intersects any viewport edge, then no adaptive label is created.
*/
buildAdaptiveLabels(view) {
this.removeAdaptiveLabels();
const numberFormat = this.style.numberFormat;
const cssColor = getCssColor(this.style.color);
const opacity = this.opacity;
const fontSize = this.style.fontSize;
const relative = this.origin === TickOrigin.Relative;
const yPrefix = relative ? '' : 'y: ';
const xPrefix = relative ? '' : 'x: ';
const hSuffix = relative ? this._unitSuffix : '';
const vSuffix = this._unitSuffix;
const dimensions = this.volume.extent.dimensions(tmpVec2);
let xOrigin = 0;
let yOrigin = 0;
let zOrigin = 0;
if (relative) {
xOrigin = 0;
yOrigin = 0;
zOrigin = this.volume.floor;
} else {
xOrigin = this.object3d.position.x - dimensions.x / 2;
yOrigin = this.object3d.position.y - dimensions.y / 2;
zOrigin = this.object3d.position.z - (this.volume.ceiling - this.volume.floor) / 2;
}
const frustum = view.frustum;
const intersect = new Vector3();
const line = new Line3();
const marginBox = new Box3();
const marginBoxSize = new Vector3(1, 1, 1);
const createLabelsForSide = side => {
if (!side.visible) {
return;
}
const matrix = side.matrixWorld;
for (let i = 0; i < side.lines.length; i++) {
const l = side.lines[i];
let prefix = '';
let suffix = '';
let offset = 0;
switch (l.axis) {
case 'X':
prefix = xPrefix;
suffix = hSuffix;
offset = xOrigin;
break;
case 'Y':
prefix = yPrefix;
suffix = hSuffix;
offset = yOrigin;
break;
case 'Z':
prefix = '';
suffix = vSuffix;
offset = zOrigin;
break;
}
// The original line has local coordinates.
line.start.copy(l.start).applyMatrix4(matrix);
line.end.copy(l.end).applyMatrix4(matrix);
const rawValue = l.labelValue + offset;
const labelValue = numberFormat.format(Math.round(rawValue));
const text = `${prefix}${labelValue}${suffix}`;
// Let's create labels that are located at the edge of the viewport.
// For each plane in the frustum, we will check if the line that this label
// belongs to intersects with the plane. If so, then we then make sure that the
// label is actually inside the frustum by using a small box rather than a point
// to reduce false negatives.
for (const plane of frustum.planes) {
if (plane.intersectLine(line, intersect) != null && frustum.intersectsBox(marginBox.setFromCenterAndSize(intersect, marginBoxSize)) === true) {
const position = intersect;
const label = this.createLabelObject(position.x, position.y, position.z, text, cssColor, opacity, fontSize);
const ndc = position.project(view.camera);
// Finally, to ensure that the label is correctly inside the viewport,
// we adjust its padding depending on the viewport edge. e.g: if the
// label is on the upper edge, we pad on the top so that it moves down.
label.element.style.padding = getPaddingForAdaptiveLabel(ndc, fontSize, text);
this._adaptiveLabelRoot.attach(label);
}
}
}
};
const floor = nonNull(this._floor);
const ceil = nonNull(this._ceiling);
const front = nonNull(this._front);
const back = nonNull(this._back);
const left = nonNull(this._left);
const right = nonNull(this._right);
createLabelsForSide(floor);
createLabelsForSide(ceil);
createLabelsForSide(front);
createLabelsForSide(back);
createLabelsForSide(left);
createLabelsForSide(right);
this._edgeLabelRoot.updateMatrixWorld(true);
}
deleteSides() {
const root = this._root;
function remove(obj) {
if (obj) {
obj.geometry.dispose();
root.remove(obj);
}
}
remove(this._floor);
remove(this._ceiling);
remove(this._front);
remove(this._back);
remove(this._left);
remove(this._right);
}
buildSides() {
this._dimensions = this.volume.extent.dimensions();
this._height = Math.abs(this.volume.ceiling - this.volume.floor);
this._midHeight = this.volume.floor + this._height / 2;
const xSize = this._dimensions.x;
const ySize = this._dimensions.y;
const zSize = this._height;
const extent = this.volume.extent;
const relative = this.origin === TickOrigin.Relative;
const xStart = relative ? 0 : this._ticks.x - mod(extent.minX, this._ticks.x);
const yStart = relative ? 0 : this._ticks.y - mod(extent.minY, this._ticks.y);
const zStart = this._ticks.z - mod(this.volume.floor, this._ticks.z);
const xMin = xStart;
const xMax = xMin + xSize;
const yMin = yStart;
const yMax = yMin + ySize;
const zMin = this.volume.floor;
const zMax = zMin + zSize;
this.deleteSides();
this._floor = this.buildSide({
name: 'floor',
horizontalLineAxis: 'X',
verticalLineAxis: 'Y',
width: xSize,
height: ySize,
xMin,
xMax,
yMin,
yMax,
xOffset: xStart,
xStep: this._ticks.x,
yOffset: yStart,
yStep: this._ticks.y
});
this._ceiling = this.buildSide({
name: 'ceiling',
horizontalLineAxis: 'X',
verticalLineAxis: 'Y',
width: xSize,
height: ySize,
xMin,
xMax,
yMin,
yMax,
xOffset: xStart,
xStep: this._ticks.x,
yOffset: yStart,
yStep: this._ticks.y
});
this._front = this.buildSide({
name: 'front',
horizontalLineAxis: 'X',
verticalLineAxis: 'Z',
width: xSize,
height: zSize,
xMin,
xMax,
yMin: zMin,
yMax: zMax,
xOffset: xStart,
xStep: this._ticks.x,
yOffset: zStart,
yStep: this._ticks.z
});
this._back = this.buildSide({
name: 'back',
horizontalLineAxis: 'X',
verticalLineAxis: 'Z',
width: xSize,
height: zSize,
xMin,
xMax,
yMin: zMin,
yMax: zMax,
xOffset: xStart,
xStep: this._ticks.x,
yOffset: zStart,
yStep: this._ticks.z
});
this._left = this.buildSide({
name: 'left',
horizontalLineAxis: 'Y',
verticalLineAxis: 'Z',
width: ySize,
height: zSize,
xMin: yMin,
xMax: yMax,
yMin: zMin,
yMax: zMax,
xOffset: yStart,
xStep: this._ticks.y,
yOffset: zStart,
yStep: this._ticks.z
});
this._right = this.buildSide({
name: 'right',
horizontalLineAxis: 'Y',
verticalLineAxis: 'Z',
width: ySize,
height: zSize,
xMin: yMin,
xMax: yMax,
yMin: zMin,
yMax: zMax,
xOffset: yStart,
xStep: this._ticks.y,
yOffset: zStart,
yStep: this._ticks.z
});
// Since the root group is located at the extent's center,
// all subsequent transformations are local to this point.
this._front.rotateX(MathUtils.degToRad(90));
this._front.position.set(0, -this._dimensions.y / 2, this._midHeight);
this._back.scale.setZ(-1);
this._back.rotateX(MathUtils.degToRad(90));
this._back.position.set(0, +this._dimensions.y / 2, this._midHeight);
this._right.rotateX(MathUtils.degToRad(90));
this._right.rotateY(MathUtils.degToRad(90));
this._right.position.set(+this._dimensions.x / 2, 0, this._midHeight);
this._left.scale.setZ(-1);
this._left.rotateX(MathUtils.degToRad(90));
this._left.rotateY(MathUtils.degToRad(90));
this._left.position.set(-this._dimensions.x / 2, 0, this._midHeight);
this._ceiling.position.set(0, 0, this.volume.ceiling);
this._floor.position.set(0, 0, this.volume.floor);
this._floor.scale.setZ(-1);
this.onObjectCreated(this._back);
this.onObjectCreated(this._left);
this.onObjectCreated(this._right);
this.onObjectCreated(this._front);
this.onObjectCreated(this._floor);
this.onObjectCreated(this._ceiling);
this._root.add(this._back);
this._root.add(this._left);
this._root.add(this._right);
this._root.add(this._front);
this._root.add(this._floor);
this._root.add(this._ceiling);
}
/**
* @param name - The name of the object.
* @param width - The width of the plane.
* @param height - The height of the plane.
* @param xOffset - The starting offset on the X axis.
* @param xStep - The distance between lines on the X axis.
* @param yOffset - The starting offset on the Y axis.
* @param yStep - The distance between lines on the Y axis.
* @returns the mesh object.
*/
buildSide(params) {
const {
name,
horizontalLineAxis,
verticalLineAxis,
width,
height,
xMin,
xMax,
yMin,
yMax,
xOffset,
xStep,
yOffset,
yStep
} = params;
const vertices = [];
const centerX = width / 2;
const centerY = height / 2;
let x = xOffset;
let y = yOffset;
const top = height;
const bottom = 0;
const left = 0;
const right = width;
const lines = [];
function pushSegment(x0, y0, x1, y1, labelValue, axis) {
const start = new Vector3(x0 - centerX, y0 - centerY, 0);
const end = new Vector3(x1 - centerX, y1 - centerY, 0);
vertices.push(start.x, start.y, start.z);
vertices.push(end.x, end.y, end.z);
const line = new Line3(start, end);
line.labelValue = labelValue;
line.axis = axis;
lines.push(line);
}
// Vertical boundary lines
pushSegment(left, bottom, left, top, xMin, verticalLineAxis);
pushSegment(right, bottom, right, top, xMax, verticalLineAxis);
// Horizontal boundary lines
pushSegment(left, bottom, right, bottom, yMin, horizontalLineAxis);
pushSegment(left, top, right, top, yMax, horizontalLineAxis);
// Horizontal subdivisions
while (x < right) {
// Avoid duplicating the boundary line
if (x !== left) {
pushSegment(x, bottom, x, top, x, horizontalLineAxis);
}
x += xStep;
}
// Vertical subdivisions
while (y < top) {
// Avoid duplicating the boundary line
if (y !== bottom) {
pushSegment(left, y, right, y, y, verticalLineAxis);
}
y += yStep;
}
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3));
const mesh = new Side(geometry, this._material, lines);
this.onObjectCreated(mesh);
mesh.name = name;
return mesh;
}
makeArrowHelper(start, end) {
if (!this._arrowRoot) {
this._arrowRoot = new Group();
this.onObjectCreated(this._arrowRoot);
nonNull(this._root.parent).add(this._arrowRoot);
}
const arrow = Helpers.createArrow(start.clone(), end.clone());
this.onObjectCreated(arrow);
this._arrowRoot.add(arrow);
arrow.updateMatrixWorld();
const startPoint = Helpers.createAxes(250);
startPoint.position.copy(start);
this.onObjectCreated(startPoint);
this._arrowRoot.add(startPoint);
startPoint.updateMatrixWorld(true);
const endPoint = Helpers.createAxes(250);
endPoint.position.copy(end);
this.onObjectCreated(endPoint);
this._arrowRoot.add(endPoint);
endPoint.updateMatrixWorld(true);
}
updateLabelsVisibility(camera) {
this._lastCamera = camera;
this.deleteArrowHelpers();
if (camera) {
this._edgeLabelRoot.children.forEach(o => this.updateLabelEdgeVisibility(camera, o));
}
}
deleteArrowHelpers() {
if (this._arrowRoot) {
const children = [...this._arrowRoot.children];
for (const child of children) {
child.removeFromParent();
}
}
}
updateLabelEdgeVisibility(camera, edge) {
if (!edge.isEdge) {
return;
}
const rootVisible = this.object3d.visible && this._edgeLabelRoot.visible;
const fontSize = this.style.fontSize;
// Labels on an edge should be displayed only if one of their side is visible,
// to prevent labels getting in the way.
//
// However, since the API enables overriding ceiling, floor or side grids visibility,
// we must distinguish between the logical visibility of the side (aka computed from the
// camera angle), and the final visibility, that also includes the API overrides.
//
// Note: HTML labels are not automatically hidden when their parent is hidden, because
// they are not really part of the scene graph, so they must be updated accordingly.
//
const logicalVisibility = edge.side1.logicalVisibility !== edge.side2.logicalVisibility;
const graphicalVisibility = edge.side1.visible || edge.side2.visible;
const visible = logicalVisibility && graphicalVisibility && rootVisible;
edge.visible = visible;
let paddingTop = 0;
let paddingBottom = 0;
let paddingRight = 0;
let paddingLeft = 0;
if (visible) {
// Now that we know this label edge is visible, we can compute the
// offset to apply (in the form of padding) to the labels so they don't overlap
// their edge line (for greater readability). We want to push the labels "outside"
// the grid. Since labels are 2D elements in the DOM, we cannot simply move
// the 3D objects around.
//
// To compute the vertical and horizontal paddings for the label in an edge,
// we must first compute the vector from the center of the grid volume toward the center
// of the label edge.
//
// Then project this vector on the screen, so that we can reason in the same
// coordinate system than the DOM.
//
// Then we can establish a quadrant to know the padding. For example, if the vector
// is pointing to the lower left corner of the screen, we know that the label must
// be pushed in this direction, so that we apply padding accordingly.
tmp.edgeCenter.set(0, 0, 0);
const edgeCenter = edge.localToWorld(tmp.edgeCenter);
const boxCenter = this._boundingBoxCenter.clone();
if (this.showHelpers) {
this.makeArrowHelper(boxCenter, edgeCenter);
}
edgeCenter.project(camera);
boxCenter.project(camera);
const clipVector = edgeCenter.sub(boxCenter);
// Our screenvector is in clip space, which is still a 3D space
// We need a purely screen-space vector.
const screenVector = tmp.v2.set(clipVector.x, clipVector.y).normalize();
const vQuadrant = UP.dot(screenVector);
const hQuadrant = RIGHT.dot(screenVector);
const zero = 0;
const limit = 0;
const yMargin = fontSize * 2;
const xMargin = fontSize * 0.7; // per character
if (vQuadrant > limit) {
paddingBottom = yMargin;
paddingTop = zero;
} else {
paddingBottom = zero;
paddingTop = yMargin;
}
if (hQuadrant > limit) {
paddingLeft = xMargin;
paddingRight = zero;
} else {
paddingLeft = zero;
paddingRight = xMargin;
}
}
const showHelpers = this.showHelpers;
edge.traverse(c => {
if (isCSS2DObject(c) && c.element != null) {
c.visible = visible;
if (visible) {
const style = c.element.style;
style.paddingTop = `${paddingTop}pt`;
style.paddingBottom = `${paddingBottom}pt`;
const charCount = c.element.innerText?.length ?? 1;
style.paddingRight = `${paddingRight * charCount}pt`;
style.paddingLeft = `${paddingLeft * charCount}pt`;
if (showHelpers) {
style.backgroundColor = 'rgba(0, 255, 0, 0.2)';
}
}
}
});
}
updateSidesVisibility(camera) {
function updateSideVisibility(side, sideVisibility, cameraNormal) {
tmp.planeNormal.setFromMatrixColumn(side.matrixWorld, 2);
// The reason why we distinguish between two kinds of visibility is because
// label visibility rules must take into account the fact that the API
// allows to manually hide the ceiling, floor, or side grids.
// Without that, we would have labels displayed when they should not.
side.logicalVisibility = cameraNormal.dot(tmp.planeNormal) < -0.1;
side.visible = sideVisibility && side.logicalVisibility;
}
// Only display sides that are facing toward the camera
updateSideVisibility(nonNull(this._front), this._showSideGrids, this._cameraForward);
updateSideVisibility(nonNull(this._back), this._showSideGrids, this._cameraForward);
updateSideVisibility(nonNull(this._right), this._showSideGrids, this._cameraForward);
updateSideVisibility(nonNull(this._left), this._showSideGrids, this._cameraForward);
updateSideVisibility(nonNull(this._ceiling), this._showCeilingGrid, this._cameraForward);
updateSideVisibility(nonNull(this._floor), this._showFloorGrid, this._cameraForward);
this.updateLabelsVisibility(camera);
}
preUpdate(context) {
if (!this.visible) {
return [];
}
if (this._needsRebuild) {
this.rebuildObjects();
this._needsRebuild = false;
}
const camera = context.view.camera;
this._cameraForward.setFromMatrixColumn(camera.matrixWorld, 2);
this.updateSidesVisibility(camera);
if (this._adaptiveLabels) {
this.buildAdaptiveLabels(context.view);
}
this.updateMinMaxDistance(context);
return [];
}
updateMinMaxDistance(context) {
const cameraPos = context.view.camera.position;
const centerDistance = this._boundingSphere.center.distanceTo(cameraPos);
const radius = this._boundingSphere.radius;
this._distance.min = centerDistance - radius;
this._distance.max = centerDistance + radius;
}
dispose() {
if (this._disposed) {
return;
}
this._disposed = true;
this._material.dispose();
this.removeEdgeLabels();
this.deleteSides();
this.deleteArrowHelpers();
}
}
export default AxisGrid;