UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

1,703 lines (1,479 loc) 81.1 kB
import proj from 'proj4'; import type { Feature, Geometry, LineString, MultiPoint, Point, Polygon, Position } from 'geojson'; import { BufferGeometry, Color, CurvePath, DoubleSide, Float32BufferAttribute, FrontSide, Group, Line3, LineCurve3, MathUtils, Mesh, MeshBasicMaterial, Raycaster, Sphere, Triangle, Vector2, Vector3, type ColorRepresentation, type Intersection, type Material, type Object3D, type WebGLRenderer, } from 'three'; import { Line2 } from 'three/examples/jsm/lines/Line2.js'; import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import { getGeometryMemoryUsage, type GetMemoryUsageContext } from '../core/MemoryUsage'; import type PickOptions from '../core/picking/PickOptions'; import type PickResult from '../core/picking/PickResult'; import ConstantSizeSphere, { getWorldSpaceRadius } from '../renderer/ConstantSizeSphere'; import { getContrastColor } from '../utils/ColorUtils'; import GeoJSONUtils from '../utils/GeoJSONUtils'; import { triangulate } from '../utils/tessellator'; import { type EntityUserData } from './Entity'; import Entity3D, { type Entity3DEventMap } from './Entity3D'; const tmpMidPoint = new Vector3(); const tmpNDC = new Vector2(); const sRGB = new Color(); const tmpSphere = new Sphere(); const tmpIntersection = new Vector3(); const DEFAULT_PICKING_RADIUS = 6; function toNumberArray(vectors: Vector3[], origin: Vector3): ArrayLike<number> { const result = new Float32Array(vectors.length * 3); for (let i = 0; i < vectors.length; i++) { const v = vectors[i]; result[i * 3 + 0] = v.x - origin.x; result[i * 3 + 1] = v.y - origin.y; result[i * 3 + 2] = v.z - origin.z; } return result; } export type Formatter<T> = (values: T) => string | null; export type LineLabelFormatOptions = { /** * The shape the lable belongs to. */ // eslint-disable-next-line no-use-before-define shape: Shape; /** * The default formatter for line labels. */ // eslint-disable-next-line no-use-before-define defaultFormatter: LineLabelFormatter; /** * The length of the segment or line, in CRS units. */ length: number; }; /** * A formatter for length values. * * Note: if the formatter returns `null`, the label is not displayed. */ export type LineLabelFormatter = Formatter<LineLabelFormatOptions>; export type SegmentLabelFormatOptions = { /** * The shape the lable belongs to. */ // eslint-disable-next-line no-use-before-define shape: Shape; /** * The default formatter for segments. */ // eslint-disable-next-line no-use-before-define defaultFormatter: SegmentLabelFormatter; /** * The length of the segment or line, in CRS units. */ length: number; /** * The coordinate of the segment start. */ start: Vector3; /** * The coordinate of the segment end. */ end: Vector3; }; /** * A formatter for segment values. * * Note: if the formatter returns `null`, the label is not displayed. */ export type SegmentLabelFormatter = Formatter<SegmentLabelFormatOptions>; export type VerticalLineFormatOptions = { /** * The shape the lable belongs to. */ // eslint-disable-next-line no-use-before-define shape: Shape; /** * The default formatter used as fallback. */ // eslint-disable-next-line no-use-before-define defaultFormatter: VerticalLineLabelFormatter; /** * The index of the vertex that this line is connected to. */ vertexIndex: number; /** * The length of the line, in CRS units. */ length: number; }; /** * A formatter for vertical lines labels. * * Note: if the formatter returns `null`, the label is not displayed. */ export type VerticalLineLabelFormatter = Formatter<VerticalLineFormatOptions>; export type SurfaceFormatOptions = { // eslint-disable-next-line no-use-before-define shape: Shape; /** * The default formatter used as fallback. */ // eslint-disable-next-line no-use-before-define defaultFormatter: SurfaceLabelFormatter; /** * The area to format, in CRS square units. */ area: number; }; /** * A formatter for the surface label. * * Note: if the formatter returns `null`, the label is not displayed. */ export type SurfaceLabelFormatter = Formatter<SurfaceFormatOptions>; // eslint-disable-next-line no-use-before-define export type SurfaceLabelPlacement = (params: { shape: Shape }) => Vector3; export type VertexFormatOptions = { // eslint-disable-next-line no-use-before-define shape: Shape; /** * The default formatter for vertices. */ // eslint-disable-next-line no-use-before-define defaultFormatter: VertexLabelFormatter; /** * The index of the vertex in the order in which they were defined. */ index: number; /** * The position of the vertex in world space. */ position: Vector3; }; export type VertexLabelFormatter = Formatter<VertexFormatOptions>; /** * A hook that is triggered just before a modification of the shape's points. * If the hook returns `false`, the operation is not performed. */ export type PreHook<T> = (args: T) => boolean; /** * A hook that is triggered just after a modification of the shape's points. */ export type PostHook<T> = (args: T) => void; /** * Hook options for point removal. */ export type RemovePointHook = { /** * The shape that triggered the hook. */ // eslint-disable-next-line no-use-before-define shape: Shape; /** * The index of the removed point. */ index: number; /** * The position of the point to remove. */ position: Vector3; }; /** * Hook options for point update. */ export type UpdatePointHook = { /** * The shape that triggered the hook. */ // eslint-disable-next-line no-use-before-define shape: Shape; /** * The index of the updated point. */ index: number; /** * The old position of the updated point. */ oldPosition: Vector3; /** * The new position of the updated point. */ newPosition: Vector3; }; /** * Hook options for point insertion. */ export type InsertPointHook = { /** * The shape that triggered the hook. */ // eslint-disable-next-line no-use-before-define shape: Shape; /** * The index of the inserted point. */ index: number; /** * The position of the inserted point. */ position: Vector3; }; const tmpIntersectList: Intersection[] = []; const KILOMETER = 1000; const SQ_KILOMETER = KILOMETER * KILOMETER; /** * The picking result for shapes. */ export type ShapePickResult = PickResult & { isShapePickResult: true; /** * The index of the picked vertex, otherwise `null`. */ pickedVertexIndex?: number; /** * The index of the first point that makes the picked segment, otherwise `null`. */ pickedSegment?: number; /** * `true` if the surface was picked, `false` otherwise. */ pickedSurface?: boolean; /** * `true` if a label was picked, `false` otherwise. */ pickedLabel?: boolean; // eslint-disable-next-line no-use-before-define entity: Shape; }; export function isShapePickResult(obj?: unknown): obj is ShapePickResult { return (obj as ShapePickResult)?.isShapePickResult; } export type ShapeExportOptions = { /** * Should the elevation/altitude of points be exported? * @defaultValue true */ includeAltitudes?: boolean; }; // eslint-disable-next-line no-use-before-define function defaultLabelPlacement(options: { shape: Shape }): Vector3 { const { points } = options.shape; // Special case of the triangle: use the barycentre if (points.length === 3 || (points.length === 4 && points[0].equals(points[3]))) { const triangle = new Triangle(points[0], points[1], points[2]); return triangle.getMidpoint(new Vector3()); } const sum = points.reduce((prev, cur) => { return prev.clone().add(cur); }); return new Vector3(sum.x / points.length, sum.y / points.length, sum.z / points.length); } function setOpacity(material: Material & { opacity: number }, opacity: number) { const current = material.opacity; if (current !== opacity) { const transparent = material.transparent; material.opacity = opacity; const newTransparent = opacity < 1; if (transparent !== newTransparent) { material.needsUpdate = true; material.transparent = newTransparent; } } } const DEFAULT_NUMBER_FORMAT = new Intl.NumberFormat(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0, }); function getAngle(A: Vector3, B: Vector3, C: Vector3): number { const AB = Math.sqrt(Math.pow(B.x - A.x, 2) + Math.pow(B.y - A.y, 2)); const BC = Math.sqrt(Math.pow(B.x - C.x, 2) + Math.pow(B.y - C.y, 2)); const AC = Math.sqrt(Math.pow(C.x - A.x, 2) + Math.pow(C.y - A.y, 2)); return Math.acos((BC * BC + AB * AB - AC * AC) / (2 * BC * AB)); } /** * A {@link VertexLabelFormatter} that displays the angle in degrees. * * Note: only acute angles (&lt; 180°) are supported. */ export const angleFormatter: Formatter<VertexFormatOptions> = (params: VertexFormatOptions) => { const { index, position, shape } = params; const A = shape.getPreviousPoint(index); const B = position; const C = shape.getNextPoint(index); if (A != null && B != null && C != null) { const angleRadians = getAngle(A, B, C); const angleDegrees = MathUtils.radToDeg(angleRadians); return `${angleDegrees.toFixed(1)}°`; } return null; }; /** * A {@link SegmentLabelFormatter} that displays the slope of the segment in percent. */ export const slopeSegmentFormatter: SegmentLabelFormatter = (params: { defaultFormatter: SegmentLabelFormatter; length: number; start: Vector3; end: Vector3; }) => { const { start, end } = params; const z = Math.min(start.z, end.z); const height = Math.max(start.z, end.z) - Math.min(start.z, end.z); const distance = new Vector3(start.x, start.y, z).distanceTo(new Vector3(end.x, end.y, z)); const slope = height / distance; const sign = start.z > end.z ? -1 : 1; return `${(sign * slope * 100).toFixed(1)}%`; }; /** * A {@link SegmentLabelFormatter} that displays the slope of the segment in degrees. */ export const angleSegmentFormatter: SegmentLabelFormatter = (params: { defaultFormatter: SegmentLabelFormatter; length: number; start: Vector3; end: Vector3; }) => { const { start, end, length } = params; const opposite = Math.max(start.z, end.z) - Math.min(start.z, end.z); const hypothenuse = length; const sin = opposite / hypothenuse; let angle = MathUtils.radToDeg(Math.asin(sin)); if (start.z > end.z) { angle = -angle; } return `${angle.toFixed(1)}°`; }; export const vertexHeightFormatter: Formatter<VertexFormatOptions> = ( options: VertexFormatOptions, ) => { const z = options.position.z; return `${z.toFixed(1)} m`; }; /** * Formats the length into a readable string. * @param length - The length of the line or segment. */ function defaultLengthFormatter(opts: { length: number }) { let unit: string; let value: number; const { length } = opts; if (length > KILOMETER * 10) { value = length / KILOMETER; unit = 'km'; } else { value = length; unit = 'm'; } return `${DEFAULT_NUMBER_FORMAT.format(value)} ${unit}`; } /** * Formats the length count into a readable string. * @param length - The length of the line or segment. */ function defaultVerticalLineFormatter(opts: { vertexIndex: number; length: number }) { return defaultLengthFormatter(opts); } /** * Formats the area into a readable string. * @param area - The area in CRS units. */ function defaultAreaFormatter(opts: { area: number }): string { let unit: string; let value: number; const { area } = opts; if (area > SQ_KILOMETER * 10) { value = area / SQ_KILOMETER; unit = 'km²'; } else { value = area; unit = 'm²'; } return `${DEFAULT_NUMBER_FORMAT.format(value)} ${unit}`; } /** * Formats the label associated with a vertex into a readable string. */ function defaultVertexFormatter(opts: { /** * The index of the vertex in the order in which they were defined. */ index: number; /** * The position of the vertex in world space. */ position: Vector3; }): string { return `${opts.index}`; } function getClosedPolygon(points: Vector3[]): Vector3[] { if (!points[points.length - 1].equals(points[0])) { return [...points, points[0]]; } return points; } function computeArea( points: Vector3[], computeGeometry: boolean, ): { area?: number; geometry?: BufferGeometry; origin?: Vector3; } { if (points.length < 2) { return { area: undefined, geometry: undefined }; } // Let's have relative point to avoid jittering const origin = points[0]; const closedPolygon = getClosedPolygon(points); const indices = triangulate(toNumberArray(points, origin), undefined); let geometry: BufferGeometry | undefined = undefined; if (computeGeometry) { const coordinateAsNumbers = toNumberArray(closedPolygon, origin); geometry = new BufferGeometry(); geometry.setAttribute('position', new Float32BufferAttribute(coordinateAsNumbers, 3)); geometry.setIndex(indices); geometry.computeBoundingBox(); } const triangleCount = indices.length / 3; let area = 0; for (let i = 0; i < triangleCount; i++) { const a = points[indices[i * 3 + 0]]; const b = points[indices[i * 3 + 1]]; const c = points[indices[i * 3 + 2]]; if (a == null || b == null || c == null) { continue; } const triangle = new Triangle(a, b, c); area += triangle.getArea(); } return { area, geometry, origin }; } export const DEFAULT_SURFACE_OPACITY = 0.35; const VERTICAL_LINE_WIDTH_FACTOR = 1; // The Giro3D blue export const DEFAULT_COLOR = '#2978b4'; export const DEFAULT_FONT_SIZE = 12; // pixels export const DEFAULT_BORDER_WIDTH = 1; // pixels export const DEFAULT_LINE_WIDTH = 2; // pixels export const DEFAULT_VERTEX_RADIUS = 4; // pixels export const DEFAULT_SHOW_VERTICES = true; export const DEFAULT_SHOW_FLOOR_VERTICES = false; export const DEFAULT_SHOW_LINE = true; export const DEFAULT_SHOW_SURFACE = false; export const DEFAULT_SHOW_VERTICAL_LINES = false; export const DEFAULT_SHOW_FLOOR_LINE = false; class Vertex extends Group { readonly isVertex = true as const; readonly type = 'Vertex' as const; private readonly _inner: ConstantSizeSphere; private readonly _outer: ConstantSizeSphere; private _borderWidth = DEFAULT_BORDER_WIDTH; private _radius = DEFAULT_VERTEX_RADIUS; get radius() { return this._radius; } set radius(radius: number) { if (this._radius !== radius) { this._radius = radius; this.update(); } } get borderWidth() { return this._borderWidth; } set borderWidth(width: number) { if (this._borderWidth !== width) { this._borderWidth = width; this.update(); } } private update() { this._inner.radius = this._radius; if (this._borderWidth > 0) { this._outer.radius = this._radius + this._borderWidth; this._outer.visible = true; } else { this._outer.visible = false; } this.updateMatrixWorld(true); } constructor(innerMaterial: MeshBasicMaterial, outerMaterial: MeshBasicMaterial) { super(); this._inner = new ConstantSizeSphere({ radius: this._radius, material: innerMaterial }); this._outer = new ConstantSizeSphere({ radius: this._radius, material: outerMaterial }); this.add(this._inner); this.add(this._outer); this.update(); this.updateMatrixWorld(true); } raycast(raycaster: Raycaster, intersects: Intersection[]): void { this._inner.raycast(raycaster, intersects); } setRenderOrder(inner: number, border: number) { this._inner.renderOrder = inner; this._outer.renderOrder = border; } } function updateResolution(material: LineMaterial, renderer: WebGLRenderer) { // We have to specify the screen size to be able to properly render // lines that have a width in pixels. Note that this should be automatically done // by three.js in the future, but for now we have to do it manually. const { width, height } = renderer.getRenderTarget() ?? renderer.getContext().canvas; material.resolution.set(width, height); } function setOnBeforeRender(material: LineMaterial) { return (renderer: WebGLRenderer) => { updateResolution(material, renderer); }; } class Label extends CSS2DObject { readonly type = 'Label' as const; readonly isLabel = true as const; readonly span: HTMLSpanElement; get pickable() { return this.span.style.pointerEvents !== 'none'; } set pickable(v: boolean) { this.span.style.pointerEvents = v ? 'auto' : 'none'; } constructor(container: HTMLElement, span: HTMLSpanElement) { super(container); this.span = span; } } /** * Represents a line with a border. * This is displayed using two lines with different * render orders and thickness to simulate the border. */ class LineWithBorder extends Group { readonly isLineWithBorder = true as const; readonly type = 'LineWithBorder' as const; private readonly _innerLine: Line2; private readonly _outerLine: Line2; readonly userData: { midPoint: Vector3; length: number; } = { midPoint: new Vector3(), length: 0, }; constructor(lineMaterial: LineMaterial, borderMaterial: LineMaterial, points: Vector3[]) { super(); const geom = new LineGeometry(); const positions = new Float32Array(points.length * 3); const first = points[0]; // Let's have relative point to avoid jittering for (let i = 0; i < points.length; i++) { positions[i * 3 + 0] = points[i].x - first.x; positions[i * 3 + 1] = points[i].y - first.y; positions[i * 3 + 2] = points[i].z - first.z; } geom.setPositions(positions); geom.computeBoundingSphere(); geom.computeBoundingBox(); this._innerLine = new Line2(geom, lineMaterial); this._outerLine = new Line2(geom, borderMaterial); this._innerLine.computeLineDistances(); this._outerLine.computeLineDistances(); this.add(this._innerLine); this.add(this._outerLine); this._innerLine.onBeforeRender = setOnBeforeRender(lineMaterial); this._outerLine.onBeforeRender = setOnBeforeRender(borderMaterial); this.position.copy(first); } setRenderOrder(main: number, border: number) { this._innerLine.renderOrder = main; this._outerLine.renderOrder = border; } removeFromParent(): this { this._innerLine.geometry.dispose(); return super.removeFromParent(); } updateMaterialResolution(renderer: WebGLRenderer): void { // Even though it's also done in onBeforeRender, this is not sufficient, // because for raycasting purposes we need to have the correct resolution set, // even for objects not rendered (out of screen). updateResolution(this._innerLine.material, renderer); updateResolution(this._outerLine.material, renderer); } raycast(raycaster: Raycaster, intersects: Intersection[]): void { this._innerLine.raycast(raycaster, intersects); } } export type ShapeFontWeight = 'bold' | 'normal'; export interface ShapeConstructorOptions { /** * Show vertices. * @defaultValue {@link DEFAULT_SHOW_VERTICES} */ showVertices?: boolean; /** * Shows the line that connects each vertex. * @defaultValue {@link DEFAULT_SHOW_LINE} */ showLine?: boolean; /** * Shows the line that is the vertical projection of the line on the plane at the {@link floorElevation}. * @defaultValue {@link DEFAULT_SHOW_FLOOR_LINE} */ showFloorLine?: boolean; /** * The floor elevation, in meters. * @defaultValue 0 */ floorElevation?: number; /** * Show vertical lines that connect each vertex to each floor vertex. * @defaultValue {@link DEFAULT_SHOW_VERTICAL_LINES} */ showVerticalLines?: boolean; /** * Shows floor vertices. * @defaultValue {@link DEFAULT_SHOW_FLOOR_VERTICES} */ showFloorVertices?: boolean; /** * Show the surface polygon. * @defaultValue {@link DEFAULT_SHOW_SURFACE} */ showSurface?: boolean; /** * The opacity of the surface. * @defaultValue {@link DEFAULT_SURFACE_OPACITY} */ surfaceOpacity?: number; /** * The specific opacity of the labels. * @defaultValue 1 */ labelOpacity?: number; /** * Make labels pickable. * @defaultValue false */ pickableLabels?: boolean; /** * Display labels for vertical lines. * @defaultValue false */ showVerticalLineLabels?: boolean; /** * Display labels for each segment of the line. * @defaultValue false */ showSegmentLabels?: boolean; /** * Display a label for the entire line. * @defaultValue false */ showLineLabel?: boolean; /** * Display a label for the surface. * @defaultValue false */ showSurfaceLabel?: boolean; /** * Display a label for each vertex. * @defaultValue false */ showVertexLabels?: boolean; /** * The main color of the shape. Affects lines, vertices, surfaces and labels. * @defaultValue {@link DEFAULT_COLOR} */ color?: ColorRepresentation; /** * The radius, in pixels, of vertices. * @defaultValue {@link DEFAULT_VERTEX_RADIUS} */ vertexRadius?: number; /** * The width, in pixels, of lines. * @defaultValue {@link DEFAULT_LINE_WIDTH} */ lineWidth?: number; /** * The width, in pixels, of the border around vertices and lines. * @defaultValue {@link DEFAULT_BORDER_WIDTH} */ borderWidth?: number; /** * The label font size. * @defaultValue {@link DEFAULT_FONT_SIZE} */ fontSize?: number; /** * The label font weight. * @defaultValue `'bold'` */ fontWeight?: ShapeFontWeight; /** * A custom formatter for the surface label. */ surfaceLabelFormatter?: SurfaceLabelFormatter; /** * An optional function to compute the location of the surface label. */ surfaceLabelPlacement?: SurfaceLabelPlacement; /** * A custom formatter for the line label. */ lineLabelFormatter?: LineLabelFormatter; /** * A custom formatter for segment labels. */ segmentLabelFormatter?: SegmentLabelFormatter; /** * A custom formatter for the vertex labels. */ vertexLabelFormatter?: VertexLabelFormatter; /** * A custom formatter for vertical line labels. */ verticalLineLabelFormatter?: VerticalLineLabelFormatter; /** * An optional hook to be called just before a point is removed. * If the hook returns `false`, the point is not removed. */ beforeRemovePoint?: PreHook<RemovePointHook>; /** * An optional hook to be called just after a point is removed. */ afterRemovePoint?: PostHook<RemovePointHook>; /** * An optional hook to be called just before a point is updated. * If the hook returns `false`, the point is not updated. */ beforeUpdatePoint?: PreHook<UpdatePointHook>; /** * An optional hook to be called just after a point is updated. */ afterUpdatePoint?: PostHook<UpdatePointHook>; /** * An optional hook to be called just before a point is inserted. * If the hook returns `false`, the point is not inserted. */ beforeInsertPoint?: PreHook<InsertPointHook>; /** * An optional hook to be called just after a point is inserted. */ afterInsertPoint?: PostHook<InsertPointHook>; } /** * An entity that displays a geometric shape made of connected vertices. * * ## Shape components * * A shape is made of several optional components: * - vertices * - main line * - secondary lines * - surface * - labels * * All components can be hidden. In that case the shape displays nothing, even though its * {@link visible} property is set to `true`. * * ### Vertices * * Vertices can be displayed for each point of the shape. * * ```js * const shape = new Shape(...); * * shape.showVertices = true; * shape.vertexRadius = 12; // pixels * ``` * * Note: vertices do not have to be displayed for the points to be editable. * * ### Main line * * The _main line_ is the line that connects the `points` of the shape. This line can form a ring * if the shape is closed (with the {@link makeClosed | makeClosed()} method). * * Note: the main line can only be displayed if there are 2 or more vertices. * * ### Surface * * If the _main line_ is a ring, the surface can be displayed by toggling {@link showSurface}. * The surface has the same color as the shape, but its opacity can be changed with {@link surfaceOpacity}. * * Note: the surface can only be displayed if there are 4 or more vertices (and the first and last vertices must be equal). * * ### Secondary lines * * _Secondary lines_ are: * - vertical lines that connect each vertex to the _floor elevation_, toggled with {@link showVerticalLines} * - the horizontal line that connect each _floor vertex_, toggled by {@link showFloorLine} * * The elevation of the floor can be set with {@link floorElevation}. * * ### Floor vertices * * _Floor vertices_ are a secondary set of uneditable vertices that connect each main vertex to the * floor elevation. They can be toggled with {@link showFloorVertices}. * * ## Styling * * The shape can be styled with different parameters: * - {@link color} to set the color of all element of the shape, including labels. * - {@link lineWidth} to set the width of the lines, in pixels. * - {@link vertexRadius} to set the radius of vertices, in pixels. * - {@link borderWidth} to set the width of the border, in pixels. * - {@link dashSize} to change the size of the dashes of secondary lines * * Note: the border color is automatically computed to provide sufficient contrast from the main color.せtぽい * * ## Labels * * Labels can be displayed for various areas of the shape: * - Labels for each vertex (toggled with {@link showVertexLabels}) * - Labels for each segment of the main line (toggled with {@link showSegmentLabels}) * - Labels for each vertical line (toggled with {@link showVerticalLineLabels}) * - A single label for the entirety of the main line (toggled with {@link showLineLabel}) * - A single label for the surface (toggled with {@link showSurfaceLabel}) * * ### Label styling * * Labels are DOM elements and are styled with three properties: * - {@link color} * - {@link fontSize} * - {@link fontWeight} * * ### Label formatting * * The text of each label is provided by a {@link Formatter}. The formatter either returns a `string` * or `null`. If `null`, the label is not displayed at all. * * |Type|Formatter|Default formatter| * |----|---------|-----------------| * |vertices|{@link VertexLabelFormatter}|Displays the vertex index| * |segments|{@link SegmentLabelFormatter}|Displays the length of the segment in metric units| * |line|{@link LineLabelFormatter}|Displays the length of the line in metric units| * |vertical lines|{@link VerticalLineLabelFormatter}|Displays the length of the line in metric units| * |surface|{@link SurfaceLabelFormatter}|Displays the area of the surface in square metric units| * * #### Formatter examples * * To display the parity of the vertex index: * * ```js * const parityFormatter = ({ vertexIndex }) => { * if (vertexIndex % 2 === 0) { * return 'even vertex'; * } else { * return 'odd vertex'; * } * } * * const shape = new Shape({ * ...options, * vertexLabelFormatter: parityFormatter * }); * ``` * * To display the length of segments in feet: * * ```js * const feetFormatter = ({ length }) => { * return `${length * 3.28084} ft`; * } * * const shape = new Shape({ * ...options, * segmentLabelFormatter: feetFormatter * }); * ``` * * To display the area of the surface in acres: * * ```js * const acresFormatter = ({ area }) => { * return `${area * 0.000247105} acres`; * } * * const shape = new Shape({ * ...options, * surfaceLabelFormatter: acresFormatter * }); * ``` * ## Hooks * * Each operation that modifies the list of the points ({@link updatePoint}, {@link removePoint}, * {@link insertPoint}, but not {@link setPoints}) triggers two hooks: * - a {@link PreHook} before the operation * - a {@link PostHook} after the operation. * * The {@link PreHook} can be used to cancel the operation by returning `false`. * * Hooks can be used to enforce constraints. For example to prevent removal of points * such that the number of points becomes insufficient to represent a polygon. * * ```js * const beforeRemovePoint = ({ shape }) => { * // Prevent removal of points if we are already at the * // minimum number of vertices to display a polygon * if (shape.points.length < 4) { * return false; * } * * return true; * } * ``` * * {@link PostHook}s can be used to update the shape after an operation. * * For example, suppose we have a 2-point shape, and we want to ensure that both points have the * same elevation (Z coordinate). Whenever a point is moved, we might also want to update the * other point. * * ```js * const afterUpdatePoint = ({ shape, index, newPosition }) => { * const z = newPosition.z; * * const otherIndex = index === 0 ? 1 : 0; * const other = shape.points[otherIndex]; * * // Prevent infinite recursion by checking that * // the point is not already at the correct height. * if (other.z !== z) { * shape.updatePoint(otherIndex, new Vector3(other.x, other.y, z)); * } * } * ``` * * ```js * const shape = new Shape({ * ...options, * afterUpdatePoint, * }); * ``` * * @typeParam UserData - The type of the {@link userData} property. */ export default class Shape<UserData extends EntityUserData = EntityUserData> extends Entity3D< Entity3DEventMap, UserData > { readonly isShape = true as const; readonly type = 'Shape' as const; private readonly _points: Vector3[] = []; private readonly _segments: Line3[] = []; // Formatters private readonly _formatLine: LineLabelFormatter = defaultLengthFormatter; private readonly _formatSegment: SegmentLabelFormatter = defaultLengthFormatter; private readonly _formatVerticalLine: VerticalLineLabelFormatter = defaultVerticalLineFormatter; private readonly _formatSurface: SurfaceLabelFormatter = defaultAreaFormatter; private readonly _surfaceLabelPlacement: SurfaceLabelPlacement = defaultLabelPlacement; private readonly _formatVertex: VertexLabelFormatter = defaultVertexFormatter; // Style private _lineWidth = DEFAULT_LINE_WIDTH; private _borderWidth = DEFAULT_BORDER_WIDTH; private _depthTest = false; private _color: Color = new Color(DEFAULT_COLOR); private _contrastColor: Color = new Color(getContrastColor(this._color)); private _fontSize = DEFAULT_FONT_SIZE; private _fontWeight: ShapeFontWeight = 'bold'; // Labels private _pickableLabels = false; private readonly _lengthLabels: Label[] = []; private readonly _vertexLabels: Label[] = []; private readonly _heightLabels: Label[] = []; private _areaLabel?: Label; private _showSegmentLabels = false; private _showVerticalLineLabels = false; private _showLineLabel = false; private _showSurfaceLabel = false; private _showVertexLabels = false; private _labelOpacity = 1; // Line private _mainLine?: LineWithBorder; private _showLine = DEFAULT_SHOW_LINE; private readonly _innerLineMaterial: LineMaterial; private readonly _outerLineMaterial: LineMaterial; // Secondary lines common options private _floorElevation = 0; private readonly _innerSecondaryLineMaterial: LineMaterial; private readonly _outerSecondaryLineMaterial: LineMaterial; // Floor lines private _floorLine?: LineWithBorder; private _showFloorLine = DEFAULT_SHOW_FLOOR_LINE; // Vertical lines private readonly _verticalLines: LineWithBorder[] = []; private _showVerticalLines = DEFAULT_SHOW_VERTICAL_LINES; // Surface (polygon) private _surface?: Mesh; private _showSurface = DEFAULT_SHOW_SURFACE; private readonly _surfaceMaterial: MeshBasicMaterial; private _surfaceOpacity = DEFAULT_SURFACE_OPACITY; // Vertices private _vertexRadius = DEFAULT_VERTEX_RADIUS; private readonly _innerVertexMaterial: MeshBasicMaterial; private readonly _outerVertexMaterial: MeshBasicMaterial; // Regular vertices private _showVertices = DEFAULT_SHOW_VERTICES; private readonly _vertices: Vertex[] = []; // Floor vertices private _showFloorVertices = DEFAULT_SHOW_FLOOR_VERTICES; private readonly _floorVertices: Vertex[] = []; // Hooks private readonly _beforeRemovePoint?: PreHook<RemovePointHook>; private readonly _afterRemovePoint?: PostHook<RemovePointHook>; private readonly _beforeUpdatePoint?: PreHook<UpdatePointHook>; private readonly _afterUpdatePoint?: PostHook<UpdatePointHook>; private readonly _beforeInsertPoint?: PreHook<InsertPointHook>; private readonly _afterInsertPoint?: PostHook<InsertPointHook>; /** * Creates a {@link Shape}. * @param options - The constructor options. */ constructor(options?: ShapeConstructorOptions) { super(new Group()); this._showVertices = options?.showVertices ?? this._showVertices; this._showFloorVertices = options?.showFloorVertices ?? this._showFloorVertices; this._showLine = options?.showLine ?? this._showLine; this._showFloorLine = options?.showFloorLine ?? this._showFloorLine; this._showVerticalLines = options?.showVerticalLines ?? this._showVerticalLines; this._showSurface = options?.showSurface ?? this._showSurface; this._labelOpacity = options?.labelOpacity ?? this._labelOpacity; this._pickableLabels = options?.pickableLabels ?? this._pickableLabels; this._showVerticalLineLabels = options?.showVerticalLineLabels ?? this._showVerticalLineLabels; this._showSegmentLabels = options?.showSegmentLabels ?? this._showSegmentLabels; this._showLineLabel = options?.showLineLabel ?? this._showLineLabel; this._showSurfaceLabel = options?.showSurfaceLabel ?? this._showSurfaceLabel; this._showVertexLabels = options?.showVertexLabels ?? this._showVertexLabels; this._color = options?.color != null ? new Color(options.color) : this._color; this._contrastColor = new Color(getContrastColor(this._color)); this._vertexRadius = options?.vertexRadius ?? this._vertexRadius; this._lineWidth = options?.lineWidth ?? this._lineWidth; this._borderWidth = options?.borderWidth ?? this._borderWidth; this._fontSize = options?.fontSize ?? this._fontSize; this._fontWeight = options?.fontWeight ?? this._fontWeight; this._innerLineMaterial = new LineMaterial({ linewidth: this._lineWidth, worldUnits: false, color: this._color, transparent: true, }); this._outerLineMaterial = new LineMaterial({ linewidth: this._lineWidth + this._borderWidth * 2, worldUnits: false, color: this._contrastColor, transparent: true, }); this._innerSecondaryLineMaterial = new LineMaterial({ linewidth: this._lineWidth, worldUnits: false, color: this._color, dashed: true, dashScale: 1, dashSize: 10, gapSize: 10, transparent: true, }); this._outerSecondaryLineMaterial = new LineMaterial({ linewidth: this._lineWidth + this._borderWidth * 2, worldUnits: false, color: this._contrastColor, dashed: true, dashScale: 1, dashSize: 10, gapSize: 10, transparent: true, }); this._innerVertexMaterial = new MeshBasicMaterial({ color: this._color, transparent: true, }); this._outerVertexMaterial = new MeshBasicMaterial({ color: this._contrastColor, side: FrontSide, transparent: true, }); this._surfaceOpacity = options?.surfaceOpacity ?? this._surfaceOpacity; this._surfaceMaterial = new MeshBasicMaterial({ color: this._color, opacity: this._surfaceOpacity, side: DoubleSide, transparent: true, }); this._formatLine = options?.lineLabelFormatter ?? this._formatLine; this._formatSurface = options?.surfaceLabelFormatter ?? this._formatSurface; this._surfaceLabelPlacement = options?.surfaceLabelPlacement ?? this._surfaceLabelPlacement; this._formatVertex = options?.vertexLabelFormatter ?? this._formatVertex; this._formatSegment = options?.segmentLabelFormatter ?? this._formatSegment; this._formatVerticalLine = options?.verticalLineLabelFormatter ?? this._formatVerticalLine; this._beforeRemovePoint = options?.beforeRemovePoint; this._afterRemovePoint = options?.afterRemovePoint; this._beforeUpdatePoint = options?.beforeUpdatePoint; this._afterUpdatePoint = options?.afterUpdatePoint; this._beforeInsertPoint = options?.beforeInsertPoint; this._afterInsertPoint = options?.afterInsertPoint; } /** * Gets or sets the specific opacity factor of the surface. * The final opacity of the surface is the product of this value with {@link opacity}. */ get surfaceOpacity() { return this._surfaceOpacity; } set surfaceOpacity(v: number) { if (this._surfaceOpacity !== v) { this._surfaceOpacity = v; this._surfaceMaterial.opacity = this.opacity * v; if (this.showSurface && this._surface) { this.notifyChange(); } } } /** * Gets or sets the opacity factor of the labels. * The final opacity of the label is the product of this value with {@link opacity}. */ get labelOpacity() { return this._labelOpacity; } set labelOpacity(v: number) { if (this._labelOpacity !== v) { this._labelOpacity = v; this.updateOpacity(); } } /** * Toggles depth test on or off. */ get depthTest() { return this._depthTest; } set depthTest(v: boolean) { if (this._depthTest !== v) { this._depthTest = v; this.updateDepthTest(); } } /** * Gets or sets the radius of the vertices, in pixels. */ get vertexRadius() { return this._vertexRadius; } set vertexRadius(radius: number) { if (this._vertexRadius !== radius) { this._vertexRadius = radius; this.visitVertices(v => (v.radius = radius)); this.notifyChange(); } } /** * Gets or sets the color of the shape. */ get color() { return this._color; } set color(c: ColorRepresentation) { const newColor = new Color(c); if (!this._color.equals(newColor)) { this._color = new Color(c); this._contrastColor = new Color(getContrastColor(this._color)); this._innerLineMaterial.color.copy(this._color); this._outerLineMaterial.color.copy(this._contrastColor); this._innerVertexMaterial.color.copy(this._color); this._outerVertexMaterial.color.copy(this._contrastColor); this._surfaceMaterial.color.copy(this._color); this._innerSecondaryLineMaterial.color.copy(this._color); this._outerSecondaryLineMaterial.color.copy(this._contrastColor); this.updateLabels(); this.notifyChange(); } } /** * Toggle the display of vertical distances (distances from each vertex to a defined elevation). */ get showVerticalLines() { return this._showVerticalLines; } set showVerticalLines(show: boolean) { if (this._showVerticalLines !== show) { this._showVerticalLines = show; this.rebuildGeometries(); } } /** * Toggle the display of floor line. */ get showFloorLine() { return this._showFloorLine; } set showFloorLine(show: boolean) { if (this._showFloorLine !== show) { this._showFloorLine = show; this.rebuildGeometries(); } } /** * Toggle the dash on lines. */ get dashed() { return this._innerSecondaryLineMaterial.dashed; } set dashed(dashed: boolean) { this._innerSecondaryLineMaterial.dashed = dashed; this._outerSecondaryLineMaterial.dashed = dashed; this.notifyChange(); } /** * The dash size. */ get dashSize() { return this._innerSecondaryLineMaterial.dashSize; } set dashSize(size: number) { if (size !== this.dashSize) { this._innerSecondaryLineMaterial.dashSize = size; this._outerSecondaryLineMaterial.dashSize = size; this._innerSecondaryLineMaterial.gapSize = size; this._outerSecondaryLineMaterial.gapSize = size; this.notifyChange(); } } /** * The floor elevation for the vertical lines. */ get floorElevation() { return this._floorElevation; } set floorElevation(floor: number) { if (this._floorElevation !== floor) { this._floorElevation = floor; this.rebuildGeometries(); } } /** * Toggle the display of vertices. */ get showVertices() { return this._showVertices; } set showVertices(show: boolean) { if (this._showVertices !== show) { this._showVertices = show; this.rebuildGeometries(); } } /** * Toggle the display of floor vertices. */ get showFloorVertices() { return this._showFloorVertices; } set showFloorVertices(show: boolean) { if (this._showFloorVertices !== show) { this._showFloorVertices = show; this.rebuildGeometries(); } } /** * Gets or sets the line width, in pixels. */ get lineWidth() { return this._lineWidth; } set lineWidth(width: number) { if (this._lineWidth !== width) { this._lineWidth = width; this._innerLineMaterial.linewidth = width; this._outerLineMaterial.linewidth = width + this._borderWidth * 2; this._innerSecondaryLineMaterial.linewidth = width * VERTICAL_LINE_WIDTH_FACTOR; this._outerSecondaryLineMaterial.linewidth = this._innerSecondaryLineMaterial.linewidth + this._borderWidth * 2; this.notifyChange(); } } /** * Gets or sets the font weight. * @defaultValue {@link DEFAULT_FONT_WEIGHT} */ get fontWeight() { return this._fontWeight; } set fontWeight(v: ShapeFontWeight) { if (this._fontWeight !== v) { this._fontWeight = v; this.updateLabels(); } } /** * Gets or sets the font size, in pixels. * @defaultValue {@link DEFAULT_FONT_SIZE} */ get fontSize() { return this._fontSize; } set fontSize(v: number) { if (this._fontSize !== v) { this._fontSize = v; this.updateLabels(); } } /** * Gets or sets the border width, in pixels. */ get borderWidth() { return this._borderWidth; } set borderWidth(width: number) { if (this._borderWidth !== width) { this._borderWidth = width; this._outerLineMaterial.linewidth = this.lineWidth + this._borderWidth * 2; this._outerSecondaryLineMaterial.linewidth = this._innerSecondaryLineMaterial.linewidth + this._borderWidth * 2; this._vertices.forEach(v => { v.borderWidth = this._borderWidth; }); this._floorVertices.forEach(v => { v.borderWidth = this._borderWidth; }); this.notifyChange(); } } /** * Toggle display of the line. */ get showLine() { return this._showLine; } set showLine(show: boolean) { if (this._showLine !== show) { this._showLine = show; this.rebuildGeometries(); } } /** * Returns the current vertex collection as a read-only array. * * Note: to modify the point collection, use {@link setPoints} instead. */ get points(): Readonly<Vector3[]> { return this._points; } /** * Inserts a point at the specified index. * @param index - The point index. * @param position - The position of the point. */ insertPoint(index: number, position: Vector3): void { if ( this._beforeInsertPoint != null && !this._beforeInsertPoint({ shape: this, index, position }) ) { return; } this._points.splice(index, 0, position); this._segments.length = 0; this.rebuildGeometries(); if (this._afterInsertPoint != null) { this._afterInsertPoint({ shape: this, index, position }); } } /** * Removes the point at the given index. * @param index - The index of the point to update. */ removePoint(index: number): void { if (this._points.length < index - 1) { return; } const position = this._points[index]; if ( this._beforeRemovePoint != null && !this._beforeRemovePoint({ shape: this, index, position }) ) { return; } this._points.splice(index, 1); this._segments.length = 0; this.rebuildGeometries(); if (this._afterRemovePoint != null) { this._afterRemovePoint({ shape: this, index, position }); } } /** * Sets the position of an existing point. * @param index - The index of the point to update. * @param newPosition - The new position of the point. */ updatePoint(index: number, newPosition: Vector3): void { if (this._points.length < index - 1) { return; } const oldPosition = this._points[index]; if (oldPosition.equals(newPosition)) { return; } if ( this._beforeUpdatePoint != null && !this._beforeUpdatePoint({ shape: this, index, oldPosition, newPosition }) ) { return; } this._points[index] = newPosition.clone(); this._segments.length = 0; this.rebuildGeometries(); if (this._afterUpdatePoint) { this._afterUpdatePoint({ shape: this, index, oldPosition, newPosition }); } } /** * Sets the points of the shape. * @param points - The points. If `null`, all points are removed. */ setPoints(points?: Vector3[]) { if (points == null || points.length === 0) { this._points.length = 0; } else { this._points.splice(0, this._points.length, ...points.map(p => p.clone())); } this._segments.length = 0; this.rebuildGeometries(); } /** * Returns the point just before the specified index, taking into account closed lines. * @param index - The point index. * @returns The location of the previous point, if any, otherwise `null`. * * Note: if the line is not closed, requesting the point before index zero will return null, * but if the line is closed, it will return the point before the last one. */ getPreviousPoint(index: number): Vector3 | null { const isClosed = this.isClosed; if (index === 0 && !this.isClosed) { return null; } if (index !== 0) { return this._points[index - 1]; } else { if (isClosed) { re