UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

1,431 lines (1,205 loc) 45.1 kB
/* * Copyright (c) 2015-2018, IGN France. * Copyright (c) 2018-2026, Giro3D team. * SPDX-License-Identifier: MIT */ import type { ColorRepresentation, Object3D } from 'three'; import { Box3, BufferGeometry, Color, Float32BufferAttribute, Group, Line3, LineBasicMaterial, LineSegments, MathUtils, Sphere, Vector2, Vector3, type Camera, } from 'three'; import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import type Context from '../core/Context'; import type Extent from '../core/geographic/Extent'; import type View from '../renderer/View'; import type { EntityUserData } from './Entity'; import type { Entity3DOptions, Entity3DEventMap } from './Entity3D'; import { getGeometryMemoryUsage, type GetMemoryUsageContext } from '../core/MemoryUsage'; import Helpers from '../helpers/Helpers'; import { isBufferGeometry, isCSS2DObject } from '../utils/predicates'; import { nonNull } from '../utils/tsutils'; import Entity3D from './Entity3D'; type Axis = 'X' | 'Y' | 'Z'; interface Line3WithLabel extends Line3 { labelValue: number; axis: Axis; } 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. */ export interface Ticks { /** The tick distance on the x axis. */ x: number; /** The tick distance on the y axis. */ y: number; /** The tick distance on the z (vertical) axis. */ z: number; } /** * The grid volume. */ export interface Volume { /** The grid volume extent. */ extent: Extent; /** The elevation of the grid floor. */ floor: number; /** The elevation of the grid ceiling. */ ceiling: number; } /** * The grid formatting options. */ export interface Style { /** The grid line and label colors. */ color: ColorRepresentation; /** The fontsize, in points (pt). */ fontSize: number; /** The number format for the labels. */ numberFormat: Intl.NumberFormat; } export const DEFAULT_STYLE: Style = { color: new Color('white'), fontSize: 10, numberFormat: new Intl.NumberFormat(), }; /** * Describes the starting point of the ticks. */ export enum TickOrigin { /** * Tick values represent distances to the grid's lower left corner */ Relative = 0, /** * Tick values represent coordinates in the CRS of the scene. */ Absolute = 1, } /** * 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: Vector3, fontSize: number, text: string): string { 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 { public readonly lines: Line3WithLabel[]; public logicalVisibility = false; public constructor( geometry: BufferGeometry, material: LineBasicMaterial, lines: Line3WithLabel[], ) { super(geometry, material); this.lines = lines; } } class Edge extends Group { public readonly isEdge = true as const; public readonly side1: Side; public readonly side2: Side; public constructor(side1: Side, side2: Side) { super(); this.side1 = side1; this.side2 = side2; } } function getCssColor(color: ColorRepresentation): string { return `#${new Color(color).getHexString()}`; } function createLabelElement( text: string, color: string, opacity: number, fontSize: number, ): { container: HTMLDivElement; label: HTMLSpanElement } { 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 }; } export interface AxisGridEventMap extends Entity3DEventMap { /** * Raised when a new label is created. */ 'label-created': { /** * The label DOM element. */ label: HTMLSpanElement; }; } /** * Constructor options for the {@link AxisGrid} entity. */ export interface AxisGridOptions extends Entity3DOptions { /** * The grid volume */ volume: Volume; /** * The origin of the ticks volume * @defaultValue {@link TickOrigin.Relative} */ origin?: TickOrigin; /** * The distance between grid lines. * @defaultValue 100 on each axis. */ ticks?: Ticks; /** * The style to apply to lines and labels. */ style?: Partial<Style>; /** * Toggles adaptive labels: labels outside the screen will be rendered at the screen edge. * @defaultValue false */ adaptiveLabels?: boolean; } /** * 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<UserData = EntityUserData> extends Entity3D<AxisGridEventMap, UserData> { public override readonly type = 'AxisGrid' as const; /** * Read-only flag to check if a given object is of type AxisGrid. */ public readonly isAxisGrid = true as const; private readonly _root: Group; private readonly _edgeLabelRoot: Group; private readonly _adaptiveLabelRoot: Group; private _style: Style; private _boundingSphere: Sphere; private _boundingBoxCenter: Vector3; private _origin: TickOrigin; private _ticks: Ticks; private _unitSuffix: string = ''; private _material: LineBasicMaterial; private _cameraForward: Vector3; private _showFloorGrid: boolean; private _showCeilingGrid: boolean; private _showSideGrids: boolean; private _showLabels = true; private _adaptiveLabels = false; private _disposed = false; private _volume: Volume; private _lastCamera: Camera | null = null; private _boundingBox: Box3 | null = null; private _dimensions: Vector2 | null = null; private _arrowRoot: Group | null = null; private _floor: Side | null = null; private _ceiling: Side | null = null; private _front: Side | null = null; private _back: Side | null = null; private _left: Side | null = null; private _right: Side | null = null; private _height: number | null = null; private _midHeight: number | null = null; private _needsRebuild = false; public showHelpers: boolean; /** * Creates an instance of AxisGrid. * * @param options - The options. */ public constructor(options: AxisGridOptions) { super(options); this._root = this.object3d as Group; 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(); } public override getMemoryUsage(context: GetMemoryUsageContext): void { this.traverse(obj => { if ('geometry' in obj && isBufferGeometry(obj.geometry)) { getGeometryMemoryUsage(context, obj.geometry); } }); } public override updateOpacity(): void { 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. */ public get style(): Style { return this._style; } public set style(v: Style) { 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. */ public get volume(): Volume { return this._volume; } public set volume(v: Volume) { 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. */ public get origin(): TickOrigin { return this._origin; } public set origin(v: TickOrigin) { if (v === undefined || v === null) { throw new Error('cannot assign undefined/null origin'); } this._origin = v; } /** * Gets or sets the grid and label color. */ public get color(): ColorRepresentation { return this.style.color; } public set color(color: ColorRepresentation) { this._material.color = new Color(color); this.style.color = color; this.refresh(); } /** * Shows or hides labels. */ public get showLabels(): boolean { return this._showLabels; } public set showLabels(v: boolean) { 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. */ public get adaptiveLabels(): boolean { return this._adaptiveLabels; } public set adaptiveLabels(v: boolean) { if (v !== this._adaptiveLabels) { this._adaptiveLabels = v; if (!v) { this.removeAdaptiveLabels(); } this.notifyChange(this); } } /** * Shows or hides the floor grid. */ public get showFloorGrid(): boolean { return this._showFloorGrid; } public set showFloorGrid(v: boolean) { if (v !== this._showFloorGrid) { this._showFloorGrid = v; this.updateVisibility(); } } /** * Shows or hides the ceiling grid. */ public get showCeilingGrid(): boolean { return this._showCeilingGrid; } public set showCeilingGrid(v: boolean) { if (v !== this._showCeilingGrid) { this._showCeilingGrid = v; this.updateVisibility(); } } /** * Shows or hides the side grids. */ public get showSideGrids(): boolean { return this._showSideGrids; } public set showSideGrids(v: boolean) { 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. */ public get ticks(): Ticks { return this._ticks; } public set ticks(v: Ticks) { if (v === undefined || v === null) { throw new Error('cannot assign undefined/null ticks'); } this._ticks = v; } private forEachLabel(callback: (label: CSS2DObject) => void): void { 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. */ public refresh(): void { this._needsRebuild = true; } private rebuildObjects(): void { 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(); } private removeEdgeLabels(): void { this._edgeLabelRoot.traverse(obj => { if (isCSS2DObject(obj)) { obj.element.remove(); } }); this._edgeLabelRoot.clear(); } private removeAdaptiveLabels(): void { this._adaptiveLabelRoot.traverse(obj => { if (isCSS2DObject(obj)) { obj.element.remove(); } }); this._adaptiveLabelRoot.clear(); } public override updateVisibility(): void { super.updateVisibility(); this.updateLabelsVisibility(this._lastCamera); } private createLabelObject( x: number, y: number, z: number, text: string, cssColor: string, opacity: number, fontSize: number, ): CSS2DObject { 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; } private buildEdgeLabels(): void { // 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: Side, side2: Side, start: Vector3, end: Vector3, startValue: number, prefix: string, suffix: string, tick: number, ): void => { 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); const step = tick / sideLength; 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 text = `${prefix}${labelValue}${suffix}`; const label = this.createLabelObject( v.x - edgeCenter.x - origin.x, v.y - edgeCenter.y - origin.y, v.z - edgeCenter.z - origin.z, text, cssColor, opacity, fontSize, ); g.add(label); t += step; } 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. */ private buildAdaptiveLabels(view: View): void { 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 zPrefix = ''; 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: Side): void => { if (!side.visible) { return; } const matrix = side.matrixWorld; for (let i = 0; i < side.lines.length; i++) { const l = side.lines[i]; let prefix: string = ''; let suffix: string = ''; 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 = zPrefix; 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); } private deleteSides(): void { const root = this._root; function remove(obj: LineSegments | null): void { 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); } private buildSides(): void { 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. */ private buildSide(params: { name: string; horizontalLineAxis: Axis; verticalLineAxis: Axis; width: number; height: number; xMin: number; xMax: number; yMin: number; yMax: number; xOffset: number; xStep: number; yOffset: number; yStep: number; }): Side { const { name, horizontalLineAxis, verticalLineAxis, width, height, xMin, xMax, yMin, yMax, xOffset, xStep, yOffset, yStep, } = params; const vertices: number[] = []; 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: Line3WithLabel[] = []; function pushSegment( x0: number, y0: number, x1: number, y1: number, labelValue: number, axis: Axis, ): void { 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) as Line3WithLabel; 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; } private makeArrowHelper(start: Vector3, end: Vector3): void { 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); } private updateLabelsVisibility(camera: Camera | null): void { this._lastCamera = camera; this.deleteArrowHelpers(); if (camera) { this._edgeLabelRoot.children.forEach(o => this.updateLabelEdgeVisibility(camera, o as Edge), ); } } private deleteArrowHelpers(): void { if (this._arrowRoot) { const children = [...this._arrowRoot.children]; for (const child of children) { child.removeFromParent(); } } } private updateLabelEdgeVisibility(camera: Camera, edge: Edge): void { 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: Object3D) => { 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)'; } } } }); } private updateSidesVisibility(camera: Camera): void { function updateSideVisibility( side: Side, sideVisibility: boolean, cameraNormal: Vector3, ): void { 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); } public override preUpdate(context: Context): object[] { if (!this.visible) { return []; } if (this._needsRebuild) { this.rebuildObjects(); this._needsRebuild = false; } const camera = context.view.camera as Camera; this._cameraForward.setFromMatrixColumn(camera.matrixWorld, 2); this.updateSidesVisibility(camera); if (this._adaptiveLabels) { this.buildAdaptiveLabels(context.view); } this.updateMinMaxDistance(context); return []; } private updateMinMaxDistance(context: Context): void { 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; } public override dispose(): void { if (this._disposed) { return; } this._disposed = true; this._material.dispose(); this.removeEdgeLabels(); this.deleteSides(); this.deleteArrowHelpers(); } } export default AxisGrid;